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

dcdpr / jp / 26881567179

03 Jun 2026 11:24AM UTC coverage: 66.675% (+0.08%) from 66.596%
26881567179

Pull #727

github

web-flow
Merge cd4b6770c into 5876e5146
Pull Request #727: feat(cli, config, tool): Add `--mount` flag and `access.fs` tool policy

737 of 1044 new or added lines in 20 files covered. (70.59%)

456 existing lines in 8 files now uncovered.

34365 of 51541 relevant lines covered (66.68%)

249.28 hits per line

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

45.14
/crates/jp_cli/src/cmd/query.rs
1
//! Query command implementation using the stream pipeline architecture.
2
//!
3
//! # Architecture Overview
4
//!
5
//! The query command handles conversational interactions with LLMs.
6
//! It uses a component-based architecture with clear separation of concerns.
7
//!
8
//! # Key Components
9
//!
10
//! - [`TurnCoordinator`]: State machine managing the turn lifecycle (Idle →
11
//!   Streaming → Executing → Complete/Aborted).
12
//!
13
//! - [`EventBuilder`]: Accumulates streamed chunks by index and produces
14
//!   complete [`ConversationEvent`]s on flush.
15
//!
16
//! - [`ChatRenderer`]: Renders LLM output (reasoning and messages) to the
17
//!   terminal with display mode support.
18
//!
19
//! - [`StreamRetryState`]: Single source of truth for stream retry logic
20
//!   (backoff, notification, state flushing).
21
//!
22
//! - [`ToolCoordinator`]: Manages parallel tool execution.
23
//!
24
//! - [`InterruptHandler`]: Handles Ctrl+C with context-aware menus (streaming
25
//!   vs tool execution).
26
//!
27
//! # Turn Lifecycle
28
//!
29
//! A "turn" is the complete interaction from user query to final response:
30
//!
31
//! 1. User sends a query ([`ChatRequest`]).
32
//! 2. LLM streams response chunks ([`ChatResponse`]).
33
//! 3. If [`ToolCallRequest`] present: execute tools, send [`ToolCallResponse`],
34
//!    goto 2 (new cycle, same turn).
35
//! 4. If no tool calls: turn complete, persist and exit.
36
//!
37
//! See `docs/architecture/query-stream-pipeline.md` for the full design
38
//! document.
39
//!
40
//! [`ChatRenderer`]: crate::render::ChatRenderer
41
//! [`ConversationEvent`]: jp_conversation::event::ConversationEvent
42
//! [`EventBuilder`]: jp_llm::event_builder::EventBuilder
43
//! [`InterruptHandler`]: interrupt::handler::InterruptHandler
44
//! [`StreamRetryState`]: stream::retry::StreamRetryState
45
//! [`ToolCallRequest`]: jp_conversation::event::ToolCallRequest
46
//! [`ToolCallResponse`]: jp_conversation::event::ToolCallResponse
47
//! [`TurnCoordinator`]: turn::coordinator::TurnCoordinator
48

49
mod interrupt;
50
mod stream;
51
pub(crate) mod tool;
52
mod turn;
53
mod turn_loop;
54

55
use std::{
56
    collections::HashSet,
57
    env, fs,
58
    io::{self, BufRead as _, IsTerminal},
59
    sync::Arc,
60
    time::Duration,
61
};
62

63
use camino::{Utf8Path, Utf8PathBuf};
64
use clap::{ArgAction, builder::TypedValueParser as _};
65
use indexmap::IndexMap;
66
use jp_attachment::Attachment;
67
use jp_config::{
68
    AppConfig, PartialAppConfig, PartialConfig as _,
69
    assignment::{AssignKeyValue as _, KvAssignment},
70
    assistant::{
71
        AssistantConfig, instructions::InstructionsConfig, sections::SectionConfig,
72
        tool_choice::ToolChoice,
73
    },
74
    conversation::{
75
        ConversationConfig,
76
        tool::{
77
            Enable, ToolSource,
78
            access::{AccessConfig, PartialAccessConfig, PartialFsRuleConfig},
79
        },
80
    },
81
    fs::{expand_tilde, load_partial},
82
    model::parameters::{PartialCustomReasoningConfig, PartialReasoningConfig, ReasoningConfig},
83
    style::reasoning::ReasoningDisplayConfig,
84
};
85
use jp_conversation::{
86
    Conversation, ConversationEvent, ConversationId, ConversationStream,
87
    event::{ChatRequest, ChatResponse},
88
    thread::{Thread, ThreadBuilder},
89
};
90
use jp_inquire::prompt::TerminalPromptBackend;
91
use jp_llm::{
92
    ToolError, provider,
93
    tool::{
94
        ToolDefinition, ToolDocs,
95
        builtin::{BuiltinExecutors, describe_tools::DescribeTools},
96
        tool_definitions,
97
    },
98
};
99
use jp_printer::Printer;
100
use jp_task::task::TitleGeneratorTask;
101
use jp_workspace::{ConversationHandle, ConversationLock, Workspace};
102
use minijinja::{Environment, UndefinedBehavior};
103
use tool::{TerminalExecutorSource, ToolCoordinator};
104
use tracing::{debug, trace, warn};
105
use turn_loop::run_turn_loop;
106

107
use super::{
108
    ConversationLoadRequest, Output, attachment::load_conversation_attachments,
109
    conversation_id::FlagIds, lock::LockOutcome,
110
};
111
use crate::{
112
    Ctx, PATH_STRING_PREFIX,
113
    access::{
114
        approvals::{APPROVALS_FILE, ApprovalLookup, ApprovalStore},
115
        compile::{ApprovalDecision, compile_policy},
116
        mount::{MountMode, MountSpec},
117
    },
118
    cmd::{
119
        self,
120
        conversation::fork,
121
        lock::{LockRequest, acquire_lock},
122
    },
123
    ctx::IntoPartialAppConfig,
124
    editor::{self, Editor},
125
    error::{Error, Result},
126
    output::print_json,
127
    parser::AttachmentUrlOrPath,
128
    render::TurnView,
129
    signals::SignalRx,
130
};
131

132
type BoxedResult<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
133

134
#[derive(Debug, Default, clap::Args)]
135
pub(crate) struct Query {
136
    /// The query to send.
137
    /// If not provided, uses `$JP_EDITOR`, `$VISUAL` or `$EDITOR` to open edit
138
    /// the query in an editor.
139
    #[arg(value_parser = string_or_path)]
140
    query: Option<Vec<String>>,
141

142
    /// Use the query string as a Jinja2 template.
143
    ///
144
    /// You can provide values for template variables using the
145
    /// `template.values` config key.
146
    #[arg(short = '%', long)]
147
    template: bool,
148

149
    /// Constrain the assistant's response to match a JSON schema.
150
    ///
151
    /// Accepts either a full JSON Schema object or a concise DSL:
152
    ///
153
    /// \-s 'summary' → single string field -s 'name, age int, bio' → mixed
154
    /// types -s 'summary: a brief summary' → field with description
155
    ///
156
    /// See: <https://jp.computer/rfd/030-schema-dsl>
157
    #[arg(short = 's', long, value_parser = string_or_path.try_map(parse_schema))]
158
    schema: Option<schemars::Schema>,
159

160
    /// Replay the last message in the conversation.
161
    ///
162
    /// If a query is provided, it will be appended to the end of the previous
163
    /// message.
164
    /// If no query is provided, $EDITOR will open with the last message in the
165
    /// conversation.
166
    #[arg(long = "replay", conflicts_with = "new")]
167
    replay: bool,
168

169
    #[command(flatten)]
170
    target: FlagIds<false, false>,
171

172
    /// Fork the session's active conversation (or the one specified by --id)
173
    /// and start a new turn on the fork.
174
    ///
175
    /// If N is given, the fork keeps only the last N turns.
176
    #[arg(
177
        long = "fork",
178
        num_args = 0..=1,
179
        default_missing_value = "",
180
        value_parser = parse_fork_turns,
181
        conflicts_with = "new",
182
    )]
183
    fork: Option<Option<usize>>,
184

185
    /// Start a new conversation without any message history.
186
    #[arg(short = 'n', long = "new", group = "new", conflicts_with = "id")]
187
    new_conversation: bool,
188

189
    /// Store the conversation locally, outside of the workspace.
190
    #[arg(
191
        short = 'l',
192
        long = "local",
193
        requires = "new_conversation",
194
        conflicts_with = "no_local"
195
    )]
196
    local: bool,
197

198
    /// Store the conversation in the current workspace.
199
    #[arg(
200
        short = 'L',
201
        long = "no-local",
202
        requires = "new_conversation",
203
        conflicts_with = "local"
204
    )]
205
    no_local: bool,
206

207
    /// Add attachment to the configuration.
208
    #[arg(short = 'a', long = "attachment", alias = "attach")]
209
    attachments: Vec<AttachmentUrlOrPath>,
210

211
    /// Whether and how to edit the query.
212
    ///
213
    /// Setting this flag to `true`, omitting it, or using it as a boolean flag
214
    /// (e.g.
215
    /// `--edit`) will use the default editor configured elsewhere, or return an
216
    /// error if no editor is configured and one is required.
217
    ///
218
    /// If set to `false`, the editor will be disabled (similar to `--no-edit`),
219
    /// which might result in an error if the editor is required.
220
    ///
221
    /// If set to any other value, it will be used as the command to open the
222
    /// editor.
223
    #[arg(short = 'e', long = "edit", conflicts_with = "no_edit")]
224
    edit: Option<Option<Editor>>,
225

226
    /// Do not edit the query.
227
    ///
228
    /// See `--edit` for more details.
229
    #[arg(short = 'E', long = "no-edit", conflicts_with = "edit")]
230
    no_edit: bool,
231

232
    /// Pre-fill the editor with the last assistant message quoted as a markdown
233
    /// blockquote (each line prefixed with ` >  `).
234
    ///
235
    /// Useful for inline replies: open `$EDITOR` with the assistant's last
236
    /// response pre-quoted, then intersperse your replies between the quoted
237
    /// lines (mutt/email style).
238
    /// The complete buffer — quotes plus your replies — becomes your next
239
    /// message.
240
    ///
241
    /// Forces the editor open by default; respects `--no-edit` / `--edit=false`
242
    /// if explicitly suppressed, in which case the quoted text is sent as-is.
243
    /// Composes with `--replay`: the quote is taken from the stream *after* the
244
    /// replayed turn has been trimmed, i.e. the assistant message preceding the
245
    /// turn being replayed.
246
    ///
247
    /// If no prior assistant message exists in this conversation, a warning is
248
    /// emitted and the editor opens with whatever other content was seeded
249
    /// (query, stdin, or empty).
250
    #[arg(long = "quote")]
251
    quote: bool,
252

253
    /// The model to use.
254
    #[arg(short = 'm', long = "model")]
255
    model: Option<String>,
256

257
    /// The model parameters to use.
258
    #[arg(short = 'p', long = "param", value_name = "KEY=VALUE", action = ArgAction::Append)]
259
    parameters: Vec<KvAssignment>,
260

261
    /// Enable reasoning.
262
    #[arg(short = 'r', long = "reasoning")]
263
    reasoning: Option<ReasoningConfig>,
264

265
    /// Disable reasoning.
266
    #[arg(short = 'R', long = "no-reasoning")]
267
    no_reasoning: bool,
268

269
    /// Do not display the reasoning content.
270
    ///
271
    /// This does not stop the assistant from generating reasoning tokens to
272
    /// help with its accuracy, but it does not display them in the output.
273
    #[arg(long = "hide-reasoning")]
274
    hide_reasoning: bool,
275

276
    /// Do not display tool calls.
277
    ///
278
    /// This does not stop the assistant from running tool calls, but it does
279
    /// not display them in the output.
280
    #[arg(long = "hide-tool-calls")]
281
    hide_tool_calls: bool,
282

283
    #[command(flatten)]
284
    tool_directives: ToolDirectives,
285

286
    /// Set the expiration date of the conversation.
287
    ///
288
    /// The conversation is persisted, but only until the conversation is no
289
    /// longer marked as active (e.g. when a new conversation is started), and
290
    /// when the expiration date is reached.
291
    ///
292
    /// This differs from `--no-persist` in that the conversation can contain
293
    /// multiple turns, as long as it remains active and not expired.
294
    #[arg(long = "tmp", requires = "new")]
295
    expires_in: Option<Option<humantime::Duration>>,
296

297
    /// Set a custom title for the conversation.
298
    ///
299
    /// Applied to the resolved conversation (new, forked, or resumed) before
300
    /// the turn runs.
301
    /// Skips title auto-generation for new conversations — your title wins.
302
    /// Mutually exclusive with `--no-title`.
303
    #[arg(long = "title", conflicts_with = "no_title")]
304
    title: Option<String>,
305

306
    /// Disable the title for the conversation.
307
    ///
308
    /// Clears any existing title on the resolved conversation (new, forked, or
309
    /// resumed) and skips auto-generation for this run.
310
    /// Mutually exclusive with `--title`.
311
    #[arg(long = "no-title", conflicts_with = "title")]
312
    no_title: bool,
313

314
    /// The tool to use.
315
    ///
316
    /// If a value is provided, the tool matching the value will be used.
317
    ///
318
    /// Note that this setting is *not* persisted across queries.
319
    /// To persist tool choice behavior, set the `assistant.tool_choice` field
320
    /// in a configuration file.
321
    #[arg(short = 'u', long = "tool-use")]
322
    tool_use: Option<Option<String>>,
323

324
    /// Disable tool use by the assistant.
325
    #[arg(short = 'U', long = "no-tool-use")]
326
    no_tool_use: bool,
327

328
    /// Mount an external path into the workspace as a symlink and grant the
329
    /// assistant access to it.
330
    ///
331
    /// Form: `[TOOL:]NAME=PATH[:MODE]`.
332
    /// `NAME` is the workspace-relative location for the symlink, `PATH` is the
333
    /// external target, and `MODE` is `ro` (default) or `rw`.
334
    /// `rw` requires a `TOOL:` prefix; without a `TOOL:` prefix the grant
335
    /// applies to all enabled local tools.
336
    /// Repeat the flag to mount several paths.
337
    #[arg(long = "mount", value_name = "[TOOL:]NAME=PATH[:MODE]", action = ArgAction::Append)]
338
    mount: Vec<String>,
339
}
340

341
impl Query {
342
    #[expect(clippy::too_many_lines)]
343
    pub(crate) async fn run(self, ctx: &mut Ctx, handle: Option<ConversationHandle>) -> Output {
2✔
344
        debug!("Running `query` command.");
2✔
345
        trace!(args = ?self, "Received arguments.");
2✔
346
        let now = ctx.now();
2✔
347
        let cfg = ctx.config();
2✔
348

349
        // Resolve the target conversation and acquire an exclusive lock.
350
        //
351
        // Three paths:
352
        // 1. --new: create a fresh conversation (already locked).
353
        // 2. --fork/--id/session: resolve an existing conversation, lock it.
354
        // 3. Lock contention: user picks "new" or "fork" from the prompt.
355
        let lock = self.acquire_lock(ctx, handle).await?;
2✔
356

357
        // Create symlinks and seed approvals for any `--mount` flags before the
358
        // turn runs, so tools can reach the mounted paths.
359
        create_mount_effects(&self.mount, &ctx.workspace, ctx.fs_backend.as_deref(), now)?;
2✔
360

361
        // The two flags are mutually exclusive (enforced by clap), and the
362
        // resolved conversation may be new, freshly forked (which clones the
363
        // source's metadata, including any title), or resumed.
364
        apply_title_override(&lock, self.title.as_deref(), self.no_title);
2✔
365

366
        // Record this conversation as the session's active conversation.
367
        if let Some(session) = &ctx.session
2✔
368
            && let Err(error) = ctx
2✔
369
                .workspace
2✔
370
                .activate_session_conversation(&lock, session, now)
2✔
371
        {
372
            warn!(%error, "Failed to record activation.");
×
373
        }
2✔
374

375
        if let Some(delta) = get_config_delta_from_cli(&cfg, &lock)? {
2✔
376
            lock.as_mut()
2✔
377
                .update_events(|events| events.add_config_delta(delta));
2✔
378
        }
×
379

380
        let mut mcp_servers_handle = ctx.configure_active_mcp_servers().await?;
2✔
381

382
        let (conv_title, is_local) = {
2✔
383
            let m = lock.metadata();
2✔
384
            (m.title.clone(), m.user)
2✔
385
        };
2✔
386

387
        // Show conversation identity in the terminal title.
388
        if ctx.term.is_tty {
2✔
389
            set_terminal_title(lock.id(), conv_title.as_deref());
×
390
        }
2✔
391

392
        let cid = lock.id();
2✔
393
        let conversation_path = ctx.fs_backend.as_deref().map_or_else(
2✔
394
            || {
×
395
                ctx.workspace
×
396
                    .root()
×
397
                    .join(cid.to_dirname(conv_title.as_deref()))
×
398
            },
×
399
            |fs| fs.build_conversation_dir(&cid, conv_title.as_deref(), is_local),
2✔
400
        );
401

402
        let (query_file, mut editor_provided_config, chat_request) = lock
2✔
403
            .as_mut()
2✔
404
            .update_events(|stream| self.build_conversation(stream, &cfg, &conversation_path))?;
2✔
405

406
        let Some(mut chat_request) = chat_request else {
×
407
            // Empty query, early exit. Auto-persist happens on lock drop.
408
            if let Some(path) = query_file.as_deref() {
×
409
                fs::remove_file(path)?;
×
410
            }
×
411
            ctx.printer.println("Query is empty, ignoring.");
×
412
            return Ok(());
×
413
        };
414

415
        // Stamp the request with the configured user name so transcripts
416
        // attribute each turn correctly even when teammates with different
417
        // local configs continue the conversation. `None` falls back to a
418
        // generic label at render time.
419
        chat_request.author = cfg.user.name.clone();
×
420

421
        // If a schema is provided, set it on the ChatRequest so the
422
        // provider uses its native structured output API.
423
        if let Some(schema) = &self.schema {
×
424
            chat_request.schema = schema.as_object().cloned();
×
425
        }
×
426

427
        // If the query was composed in an editor, the user has lost sight
428
        // of what they wrote by the time the editor closes. Echo it back
429
        // through the same role-aware rendering machinery used by replay
430
        // and live streaming — a labeled user header followed by the
431
        // request body — so the boundary between user input and the
432
        // forthcoming assistant response is visually clear. Render this
433
        // before any post-edit work (MCP init, attachments, tools) so that
434
        // failures in those stages don't swallow the user's message.
435
        if query_file.is_some() {
×
436
            let mut echo = TurnView::new(
×
437
                ctx.printer.clone(),
×
438
                cfg.style.clone(),
×
439
                cfg.assistant.name.clone(),
×
440
                Some(cfg.assistant.model.id.resolved().to_string()),
×
441
            );
×
442
            echo.render_user_request(&chat_request);
×
443
        }
×
444

445
        if !editor_provided_config.is_empty() {
×
446
            // Resolve any model aliases before storing in the stream so
447
            // that per-event configs always contain concrete model IDs.
448
            editor_provided_config.resolve_model_aliases(&cfg.providers.llm.aliases);
×
449
            lock.as_mut()
×
450
                .update_events(|events| events.add_config_delta(editor_provided_config));
×
451
        }
×
452

453
        let stream = lock.events().clone();
×
454

455
        // Generate title for new or empty conversations (including forks).
456
        // Skip when `--title` or `--no-title` was provided (the user already
457
        // expressed an intent for the title), or when the resolved config
458
        // has auto-generation disabled.
459
        if (self.is_new() || self.fork.is_some() || stream.is_empty())
×
460
            && ctx.term.args.persist
×
461
            && self.title.is_none()
×
462
            && !self.no_title
×
463
            && cfg.conversation.title.generate.auto
×
464
        {
465
            debug!("Generating title for new conversation");
×
466
            let mut stream = stream.clone();
×
467
            stream.start_turn(chat_request.clone());
×
468
            ctx.task_handler
×
469
                .spawn(TitleGeneratorTask::new(cid, stream, &cfg, ctx.term.is_tty)?);
×
470
        }
×
471

472
        // Wait for all MCP servers to finish loading.
473
        while let Some(result) = mcp_servers_handle.join_next().await {
×
474
            result??;
×
475
        }
476

477
        let forced_tool = cfg.assistant.tool_choice.function_name();
×
478
        let tools =
×
479
            tool_definitions(cfg.conversation.tools.iter(), &ctx.mcp_client, forced_tool).await?;
×
480

481
        let attachment_urls: Vec<_> = cfg
×
482
            .conversation
×
483
            .attachments
×
484
            .iter()
×
485
            .map(jp_config::conversation::attachment::AttachmentConfig::to_url)
×
486
            .collect::<std::result::Result<Vec<_>, _>>()?;
×
487
        let attachments = load_conversation_attachments(ctx, attachment_urls).await?;
×
488

489
        debug!(count = attachments.len(), "Attachments loaded.");
×
490

491
        let thread = build_thread(stream, attachments, &cfg.assistant, !tools.is_empty())?;
×
492
        let root = ctx.workspace.root().to_path_buf();
×
NEW
493
        let approvals = Arc::new(load_approval_store(ctx.fs_backend.as_deref()));
×
494

495
        // Sanitize any structural issues (orphaned tool calls, missing
496
        // user messages, etc.) before sending the stream to the provider.
497
        lock.as_mut().update_events(ConversationStream::sanitize);
×
498

499
        let turn_result = self
×
500
            .handle_turn(
×
501
                &cfg,
×
502
                &ctx.signals.receiver,
×
503
                &ctx.mcp_client,
×
504
                root,
×
505
                ctx.term.is_tty,
×
506
                &thread.attachments,
×
507
                &lock,
×
508
                cfg.assistant.tool_choice.clone(),
×
509
                &tools,
×
510
                ctx.printer.clone(),
×
NEW
511
                approvals,
×
512
                chat_request,
×
513
            )
×
514
            .await
×
515
            .map_err(|error| cmd::Error::from(error).with_persistence(true));
×
516

517
        // Extract structured data from the conversation after the turn.
518
        if self.schema.is_some() && turn_result.is_ok() {
×
519
            let data = lock.events().iter().rev().find_map(|e| {
×
520
                e.as_chat_response()
×
521
                    .and_then(ChatResponse::as_structured_data)
×
522
                    .cloned()
×
523
            });
×
524

525
            match data {
×
526
                Some(data) => print_json(&ctx.printer, &data),
×
527
                None => return Err(Error::MissingStructuredData.into()),
×
528
            }
529
        }
×
530

531
        // Clean up the query file, unless we got an error.
532
        if let Some(path) = query_file
×
533
            && turn_result.is_ok()
×
534
        {
535
            fs::remove_file(path)?;
×
536
        }
×
537

538
        turn_result
×
539
    }
2✔
540

541
    /// Declare what conversations this command needs.
542
    pub(crate) fn conversation_load_request(&self) -> ConversationLoadRequest {
2✔
543
        if self.is_new() {
2✔
544
            return ConversationLoadRequest::none();
×
545
        }
2✔
546

547
        ConversationLoadRequest::explicit_or_session_with_config(&self.target)
2✔
548
    }
2✔
549

550
    /// Build the chat request for this query.
551
    ///
552
    /// Returns the editor details and the [`ChatRequest`], if non-empty.
553
    /// The request is **not** added to the stream — that is the responsibility
554
    /// of [`TurnCoordinator::start_turn`].
555
    ///
556
    /// [`TurnCoordinator::start_turn`]: turn::TurnCoordinator::start_turn
557
    fn build_conversation(
2✔
558
        &self,
2✔
559
        stream: &mut ConversationStream,
2✔
560
        config: &AppConfig,
2✔
561
        conversation_root: &Utf8Path,
2✔
562
    ) -> Result<(Option<Utf8PathBuf>, PartialAppConfig, Option<ChatRequest>)> {
2✔
563
        // If replaying, remove all events up-to-and-including the last
564
        // `ChatRequest` event, which we'll replay.
565
        //
566
        // If not replaying (or replaying but no chat request event exists), we
567
        // create a new `ChatRequest` event, to populate with either the
568
        // provided query, or the contents of the text editor.
569
        let mut chat_request = self
2✔
570
            .replay
2✔
571
            .then(|| stream.trim_chat_request())
2✔
572
            .flatten()
2✔
573
            .unwrap_or_default();
2✔
574

575
        // If stdin contains data, we prepend it to the chat request.
576
        let stdin = io::stdin();
2✔
577
        let piped = if stdin.is_terminal() {
2✔
578
            String::new()
×
579
        } else {
580
            stdin
2✔
581
                .lock()
2✔
582
                .lines()
2✔
583
                .map_while(std::result::Result::ok)
2✔
584
                .collect::<String>()
2✔
585
        };
586

587
        if !piped.is_empty() {
2✔
588
            let sep = if chat_request.is_empty() { "" } else { "\n\n" };
×
589
            *chat_request = format!("{piped}{sep}{chat_request}");
×
590
        }
2✔
591

592
        // If a query is provided, prepend it to the chat request. This is only
593
        // relevant for replays, otherwise the chat request is still empty, so
594
        // we replace it with the provided query.
595
        if let Some(text) = &self.query {
2✔
596
            let text = text.join(" ");
×
597
            let sep = if chat_request.is_empty() { "" } else { "\n\n" };
×
598
            *chat_request = format!("{text}{sep}{chat_request}");
×
599
        }
2✔
600

601
        // If --quote is set, prepend the last assistant message as a markdown
602
        // blockquote so it sits at the top of the editor buffer. The user can
603
        // then intersperse replies between the quoted lines (mutt-style inline
604
        // reply). Missing message (e.g. brand new conversation) degrades to a
605
        // warning and the editor opens with whatever else was seeded.
606
        if self.quote {
2✔
607
            if let Some(message) = last_assistant_message(stream) {
×
608
                let quoted = blockquote(message);
×
609
                *chat_request = format!("{quoted}\n\n{chat_request}");
×
610
            } else {
×
611
                warn!("--quote: no prior assistant message in this conversation");
×
612
            }
613
        }
2✔
614

615
        let (query_file, editor_provided_config) = self.edit_message(
2✔
616
            &mut chat_request,
2✔
617
            stream,
2✔
618
            !piped.is_empty(),
2✔
619
            config,
2✔
620
            conversation_root,
2✔
621
        )?;
2✔
622

623
        if self.template {
×
624
            let mut env = Environment::empty();
×
625
            env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
×
626
            env.add_template("query", &chat_request.content)?;
×
627

628
            let tmpl = env.get_template("query")?;
×
629
            // TODO: supported nested variables
630
            for var in tmpl.undeclared_variables(false) {
×
631
                if config.template.values.contains_key(&var) {
×
632
                    continue;
×
633
                }
×
634

635
                return Err(Error::TemplateUndefinedVariable(var));
×
636
            }
637

638
            *chat_request = tmpl.render(&config.template.values)?;
×
639
        }
×
640

641
        Ok((
×
642
            query_file,
×
643
            editor_provided_config,
×
644
            (!chat_request.is_empty()).then_some(chat_request),
×
645
        ))
×
646
    }
2✔
647

648
    /// Create a new conversation and return an exclusive lock.
649
    fn create_new_conversation(&self, ctx: &mut Ctx) -> Result<ConversationLock> {
×
650
        let cfg = ctx.config();
×
651
        let ws = &mut ctx.workspace;
×
652

653
        let conversation = Conversation::default().with_local(self.is_local(&cfg.conversation));
×
654
        let lock =
×
655
            ws.create_and_lock_conversation(conversation, cfg.clone(), ctx.session.as_ref())?;
×
656
        let id = lock.id();
×
657

658
        if let Some(duration) = self.expires_in_duration() {
×
659
            let mut conv = lock.as_mut();
×
660
            conv.update_metadata(|m| {
×
661
                m.expires_at = chrono::Duration::from_std(duration)
×
662
                    .ok()
×
663
                    .and_then(|v| id.timestamp().checked_add_signed(v));
×
664
            });
×
665
            conv.flush()?;
×
666
        }
×
667

668
        debug!(
×
669
            id = id.to_string(),
×
670
            local = self.is_local(&cfg.conversation),
×
671
            expires_in = self.expires_in_duration().map_or_else(
×
672
                || "when inactive".to_owned(),
×
673
                |v| humantime::format_duration(v).to_string()
×
674
            ),
675
            "Creating new conversation."
676
        );
677

678
        Ok(lock)
×
679
    }
×
680

681
    // Open the editor for the query, if requested.
682
    fn edit_message(
2✔
683
        &self,
2✔
684
        request: &mut ChatRequest,
2✔
685
        stream: &mut ConversationStream,
2✔
686
        piped: bool,
2✔
687
        config: &AppConfig,
2✔
688
        conversation_root: &Utf8Path,
2✔
689
    ) -> Result<(Option<Utf8PathBuf>, PartialAppConfig)> {
2✔
690
        // If there is no query provided, but the user explicitly requested not
691
        // to open the editor, we populate the query with a default message,
692
        // since most LLM providers do not support empty queries.
693
        //
694
        // See `force_no_edit` why this can be useful.
695
        if request.is_empty() && self.force_no_edit() {
2✔
696
            // If the last event in the stream is a `ChatRequest`, we don't add
697
            // anything, and simply "replay" the last message in the
698
            // conversation.
699
            //
700
            // Otherwise we add a default "continue" message.
701
            if let Some(last) = stream.pop_if(ConversationEvent::is_chat_request)
×
702
                && let Some(req) = last.into_inner().into_chat_request()
×
703
            {
×
704
                *request = req;
×
705
            } else {
×
706
                "continue".clone_into(request);
×
707
            }
×
708
        }
2✔
709

710
        // If a query is provided, and editing is not explicitly requested, or
711
        // in addition to the query, stdin contains data, we omit opening the
712
        // editor.
713
        if (self.query.as_ref().is_some_and(|v| !v.is_empty()) || !piped)
2✔
714
            && !self.force_edit()
2✔
715
            && !request.is_empty()
2✔
716
        {
717
            return Ok((None, PartialAppConfig::empty()));
×
718
        }
2✔
719

720
        let editor = match config.editor.command() {
2✔
721
            None if !request.is_empty() => return Ok((None, PartialAppConfig::empty())),
2✔
722
            None => return Err(Error::MissingEditor),
2✔
723
            Some(cmd) => cmd,
×
724
        };
725

726
        let (content, query_file, editor_provided_config) = editor::edit_query(
×
727
            config,
×
728
            conversation_root,
×
729
            stream,
×
730
            request.as_str(),
×
731
            editor,
×
732
            None,
×
733
        )?;
×
734
        request.content = content;
×
735

736
        Ok((Some(query_file), editor_provided_config))
×
737
    }
2✔
738

739
    /// Handle a single turn of conversation with the LLM.
740
    #[expect(clippy::too_many_arguments)]
741
    async fn handle_turn(
×
742
        &self,
×
743
        cfg: &AppConfig,
×
744
        signals: &SignalRx,
×
745
        mcp_client: &jp_mcp::Client,
×
746
        root: Utf8PathBuf,
×
747
        is_tty: bool,
×
748
        attachments: &[Attachment],
×
749
        lock: &ConversationLock,
×
750
        tool_choice: ToolChoice,
×
751
        tools: &[ToolDefinition],
×
752
        printer: Arc<Printer>,
×
NEW
753
        approvals: Arc<ApprovalStore>,
×
754
        chat_request: ChatRequest,
×
755
    ) -> Result<()> {
×
756
        let model_id = cfg.assistant.model.id.resolved();
×
757
        let provider: Arc<dyn jp_llm::Provider> = Arc::from(provider::get_provider(
×
758
            model_id.provider,
×
759
            &cfg.providers.llm,
×
760
        )?);
×
761
        debug!(model = %model_id, "Fetching model details.");
×
762
        let model = provider.model_details(&model_id.name).await?;
×
763
        debug!(model = model.name(), "Model details resolved.");
×
764

765
        // Build docs map from the resolved definitions for describe_tools.
766
        let docs_map: IndexMap<String, ToolDocs> = tools
×
767
            .iter()
×
768
            .map(|t| (t.name.clone(), t.docs.clone()))
×
769
            .collect();
×
770
        let builtin_executors =
×
771
            BuiltinExecutors::new().register("describe_tools", DescribeTools::new(docs_map));
×
NEW
772
        let executor_source = TerminalExecutorSource::new(builtin_executors, tools, approvals);
×
773
        let tool_coordinator =
×
774
            ToolCoordinator::new(cfg.conversation.tools.clone(), Box::new(executor_source));
×
775
        let prompt_backend = Arc::new(TerminalPromptBackend);
×
776

777
        run_turn_loop(
×
778
            provider,
×
779
            &model,
×
780
            cfg,
×
781
            signals,
×
782
            mcp_client,
×
783
            &root,
×
784
            is_tty,
×
785
            attachments,
×
786
            lock,
×
787
            tool_choice,
×
788
            tools,
×
789
            printer,
×
790
            prompt_backend,
×
791
            tool_coordinator,
×
792
            chat_request,
×
793
        )
×
794
        .await
×
795
    }
×
796

797
    /// Returns `true` if editing is explicitly disabled.
798
    ///
799
    /// This signals that even if no query is provided, no editor should be
800
    /// opened, but instead an empty query should be used.
801
    ///
802
    /// This can be used for example when requesting a tool call without needing
803
    /// additional context to be provided.
804
    fn force_no_edit(&self) -> bool {
4✔
805
        self.no_edit || matches!(self.edit, Some(Some(Editor::Disabled)))
4✔
806
    }
4✔
807

808
    /// Returns `true` if editing is explicitly enabled.
809
    ///
810
    /// This means the `--edit` flag was provided (but not `--edit=false`), or
811
    /// `--quote` was provided (which implies editing).
812
    /// In either case the editor should be opened, regardless of whether a
813
    /// query is provided as an argument.
814
    fn force_edit(&self) -> bool {
2✔
815
        !self.force_no_edit() && (self.edit.is_some() || self.quote)
2✔
816
    }
2✔
817

818
    #[must_use]
819
    fn is_local(&self, cfg: &ConversationConfig) -> bool {
×
820
        (self.local || cfg.start_local) && !self.no_local
×
821
    }
×
822

823
    #[must_use]
824
    fn is_new(&self) -> bool {
4✔
825
        self.new_conversation
4✔
826
    }
4✔
827

828
    #[must_use]
829
    fn expires_in_duration(&self) -> Option<Duration> {
×
830
        self.expires_in?
×
831
            .map(Duration::from)
×
832
            .or_else(|| Some(Duration::new(0, 0)))
×
833
    }
×
834

835
    async fn acquire_lock(
2✔
836
        &self,
2✔
837
        ctx: &mut Ctx,
2✔
838
        handle: Option<ConversationHandle>,
2✔
839
    ) -> Result<ConversationLock> {
2✔
840
        // Handle --new: create a fresh conversation.
841
        if self.is_new() {
2✔
842
            return self.create_new_conversation(ctx);
×
843
        }
2✔
844

845
        let handle = handle.ok_or(Error::NoConversationTarget)?;
2✔
846

847
        // Handle --fork: fork the conversation before locking.
848
        if let Some(fork_turns) = &self.fork {
2✔
849
            return fork_conversation(ctx, &handle, *fork_turns);
×
850
        }
2✔
851

852
        let req = LockRequest::from_ctx(handle, ctx)
2✔
853
            .allow_new(true)
2✔
854
            .allow_fork(true);
2✔
855

856
        match acquire_lock(req).await? {
2✔
857
            LockOutcome::Acquired(lock) => Ok(lock),
2✔
858
            LockOutcome::NewConversation => self.create_new_conversation(ctx),
×
859
            LockOutcome::ForkConversation(handle) => fork_conversation(ctx, &handle, None),
×
860
        }
861
    }
2✔
862
}
863

864
/// Return the most recent assistant message text in the stream.
865
///
866
/// Walks the stream in reverse and returns the first `ChatResponse::Message` it
867
/// encounters.
868
/// Reasoning, structured-data responses, and tool calls are skipped.
869
fn last_assistant_message(stream: &ConversationStream) -> Option<&str> {
4✔
870
    stream
4✔
871
        .iter()
4✔
872
        .rev()
4✔
873
        .filter_map(|e| e.event.as_chat_response())
6✔
874
        .find_map(|r| r.as_message())
4✔
875
}
4✔
876

877
/// Prefix each line of `text` with ` >  ` for use as a markdown blockquote.
878
///
879
/// Empty lines are emitted as just `>` (no trailing space) so the blockquote
880
/// stays visually continuous across paragraph breaks while avoiding
881
/// trailing-whitespace warnings in editors.
882
fn blockquote(text: &str) -> String {
5✔
883
    text.lines()
5✔
884
        .map(|line| {
11✔
885
            if line.is_empty() {
11✔
886
                ">".to_owned()
1✔
887
            } else {
888
                format!("> {line}")
10✔
889
            }
890
        })
11✔
891
        .collect::<Vec<_>>()
5✔
892
        .join("\n")
5✔
893
}
5✔
894

895
/// A single tool selection directive from the CLI.
896
///
897
/// Directives are evaluated left-to-right, allowing users to compose tool sets
898
/// precisely (e.g.
899
/// `--no-tools --tool=write --no-tools=fs_modify_file`).
900
#[derive(Debug, Clone, PartialEq, Eq)]
901
enum ToolDirective {
902
    EnableAll,
903
    DisableAll,
904
    Enable(String),
905
    Disable(String),
906
}
907

908
impl ToolDirective {
909
    /// Returns the single-tool directive as a string slice.
910
    #[must_use]
911
    fn as_single(&self) -> Option<&str> {
19✔
912
        match self {
19✔
913
            Self::Enable(name) | Self::Disable(name) => Some(name.as_str()),
10✔
914
            _ => None,
9✔
915
        }
916
    }
19✔
917
}
918

919
/// Ordered sequence of tool directives parsed from `--tool` and `--no-tools`.
920
///
921
/// Implements manual [`clap::Args`] and [`clap::FromArgMatches`] to recover the
922
/// position of each flag value using [`ArgMatches::indices_of`], then merges
923
/// and sorts them by index into a single ordered list.
924
///
925
/// [`ArgMatches::indices_of`]: clap::ArgMatches::indices_of
926
#[derive(Debug, Clone, Default)]
927
struct ToolDirectives(Vec<ToolDirective>);
928

929
impl std::ops::Deref for ToolDirectives {
930
    type Target = [ToolDirective];
931

932
    fn deref(&self) -> &Self::Target {
59✔
933
        &self.0
59✔
934
    }
59✔
935
}
936

937
impl clap::FromArgMatches for ToolDirectives {
938
    fn from_arg_matches(matches: &clap::ArgMatches) -> std::result::Result<Self, clap::Error> {
2✔
939
        let tool_values: Vec<String> = matches
2✔
940
            .get_many("tools")
2✔
941
            .map(|v| v.cloned().collect())
2✔
942
            .unwrap_or_default();
2✔
943
        let tool_indices: Vec<_> = matches
2✔
944
            .indices_of("tools")
2✔
945
            .map(Iterator::collect)
2✔
946
            .unwrap_or_default();
2✔
947

948
        let no_tool_values: Vec<String> = matches
2✔
949
            .get_many("no_tools")
2✔
950
            .map(|v| v.cloned().collect())
2✔
951
            .unwrap_or_default();
2✔
952
        let no_tool_indices: Vec<_> = matches
2✔
953
            .indices_of("no_tools")
2✔
954
            .map(Iterator::collect)
2✔
955
            .unwrap_or_default();
2✔
956

957
        let mut indexed = vec![];
2✔
958
        for (val, idx) in tool_values.into_iter().zip(tool_indices) {
2✔
959
            let directive = if val.is_empty() {
×
960
                ToolDirective::EnableAll
×
961
            } else {
962
                ToolDirective::Enable(val)
×
963
            };
964
            indexed.push((idx, directive));
×
965
        }
966

967
        for (val, idx) in no_tool_values.into_iter().zip(no_tool_indices) {
2✔
968
            let directive = if val.is_empty() {
×
969
                ToolDirective::DisableAll
×
970
            } else {
971
                ToolDirective::Disable(val)
×
972
            };
973
            indexed.push((idx, directive));
×
974
        }
975

976
        indexed.sort_by_key(|(idx, _)| *idx);
2✔
977
        Ok(Self(indexed.into_iter().map(|(_, d)| d).collect()))
2✔
978
    }
2✔
979

980
    fn update_from_arg_matches(
×
981
        &mut self,
×
982
        matches: &clap::ArgMatches,
×
983
    ) -> std::result::Result<(), clap::Error> {
×
984
        *self = Self::from_arg_matches(matches)?;
×
985
        Ok(())
×
986
    }
×
987
}
988

989
impl clap::Args for ToolDirectives {
990
    fn augment_args(cmd: clap::Command) -> clap::Command {
3✔
991
        cmd.arg(
3✔
992
            clap::Arg::new("tools")
3✔
993
                .short('t')
3✔
994
                .long("tool")
3✔
995
                .alias("tools")
3✔
996
                .help("The tool(s) to enable")
3✔
997
                .long_help(
3✔
998
                    "The tool(s) to enable.\n\nIf an existing tool is configured with a matching \
999
                     name, it will be enabled for the duration of the query.\n\nIf no arguments \
1000
                     are provided, all configured tools will be enabled.\n\nYou can provide this \
1001
                     flag multiple times to enable multiple tools. Flags are evaluated \
1002
                     left-to-right, so `--no-tools --tool=write` first disables everything, then \
1003
                     re-enables only 'write'.",
1004
                )
1005
                .action(ArgAction::Append)
3✔
1006
                .num_args(0..=1)
3✔
1007
                .default_missing_value(""),
3✔
1008
        )
1009
        .arg(
3✔
1010
            clap::Arg::new("no_tools")
3✔
1011
                .short('T')
3✔
1012
                .long("no-tool")
3✔
1013
                .alias("no-tools")
3✔
1014
                .help("Disable tool(s)")
3✔
1015
                .long_help(
3✔
1016
                    "Disable tool(s).\n\nIf provided without a value, all enabled tools will be \
1017
                     disabled, otherwise pass the argument multiple times to disable one or more \
1018
                     tools.\n\nFlags are evaluated left-to-right together with `--tool`.",
1019
                )
1020
                .action(ArgAction::Append)
3✔
1021
                .num_args(0..=1)
3✔
1022
                .default_missing_value(""),
3✔
1023
        )
1024
    }
3✔
1025

1026
    fn augment_args_for_update(cmd: clap::Command) -> clap::Command {
×
1027
        Self::augment_args(cmd)
×
1028
    }
×
1029
}
1030

1031
/// Fork a conversation and return the new conversation's lock.
1032
fn fork_conversation(
×
1033
    ctx: &mut Ctx,
×
1034
    source: &ConversationHandle,
×
1035
    fork_turns: Option<usize>,
×
1036
) -> Result<ConversationLock> {
×
1037
    fork::fork_conversation(ctx, source, |events| {
×
1038
        if let Some(n) = fork_turns {
×
1039
            events.retain_last_turns(n);
×
1040
        }
×
1041
    })
×
1042
}
×
1043

1044
/// Apply `--title` / `--no-title` to the resolved conversation.
1045
///
1046
/// Both flags act on `metadata.title` directly so the run ends with the title
1047
/// the user asked for, regardless of whether the conversation is new, freshly
1048
/// forked (which inherits the source's title), or resumed:
1049
///
1050
/// - `--title T` sets the title to `Some(T)`.
1051
/// - `--no-title` clears any existing title.
1052
/// - Neither flag is a no-op.
1053
fn apply_title_override(lock: &ConversationLock, title: Option<&str>, no_title: bool) {
6✔
1054
    if let Some(title) = title {
6✔
1055
        lock.as_mut().update_metadata(|m| {
1✔
1056
            m.title = Some(title.to_owned());
1✔
1057
        });
1✔
1058
    } else if no_title {
5✔
1059
        lock.as_mut().update_metadata(|m| {
2✔
1060
            m.title = None;
2✔
1061
        });
2✔
1062
    }
3✔
1063
}
6✔
1064

1065
fn get_config_delta_from_cli(
4✔
1066
    cfg: &AppConfig,
4✔
1067
    lock: &ConversationLock,
4✔
1068
) -> Result<Option<PartialAppConfig>> {
4✔
1069
    let partial = lock
4✔
1070
        .events()
4✔
1071
        .config()
4✔
1072
        .map(|c| c.to_partial())
4✔
1073
        .map_err(jp_conversation::Error::from)?;
4✔
1074

1075
    let partial = partial.delta(cfg.to_partial());
4✔
1076
    if partial.is_empty() {
4✔
1077
        return Ok(None);
×
1078
    }
4✔
1079

1080
    Ok(Some(partial))
4✔
1081
}
4✔
1082

1083
impl IntoPartialAppConfig for Query {
1084
    fn apply_cli_config(
23✔
1085
        &self,
23✔
1086
        workspace: Option<&Workspace>,
23✔
1087
        mut partial: PartialAppConfig,
23✔
1088
        merged_config: Option<&PartialAppConfig>,
23✔
1089
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
23✔
1090
        let Self {
1091
            model,
23✔
1092
            template: _,
1093
            schema: _,
1094
            replay: _,
1095
            new_conversation: _,
1096
            local: _,
1097
            no_local: _,
1098
            attachments,
23✔
1099
            edit,
23✔
1100
            no_edit,
23✔
1101
            quote: _,
1102
            tool_use,
23✔
1103
            no_tool_use,
23✔
1104
            query: _,
1105
            parameters,
23✔
1106
            hide_reasoning,
23✔
1107
            hide_tool_calls,
23✔
1108
            tool_directives,
23✔
1109
            reasoning,
23✔
1110
            no_reasoning,
23✔
1111
            expires_in: _,
1112
            target: _,
1113
            fork: _,
1114
            title: _,
1115
            no_title: _,
1116
            mount,
23✔
1117
        } = &self;
23✔
1118

1119
        apply_model(&mut partial, model.as_deref(), merged_config);
23✔
1120
        apply_editor(&mut partial, edit.as_ref().map(|v| v.as_ref()), *no_edit);
23✔
1121

1122
        // Inject builtin tool configs before tool-enable processing.
1123
        for (name, config) in tool::builtins::all() {
23✔
1124
            partial
23✔
1125
                .conversation
23✔
1126
                .tools
23✔
1127
                .tools
23✔
1128
                .entry(name)
23✔
1129
                .or_insert(config);
23✔
1130
        }
23✔
1131

1132
        apply_enable_tools(&mut partial, tool_directives, merged_config)?;
23✔
1133
        apply_tool_use(
23✔
1134
            &mut partial,
23✔
1135
            tool_use.as_ref().map(|v| v.as_deref()),
23✔
1136
            *no_tool_use,
23✔
1137
        )?;
×
1138
        apply_attachments(&mut partial, attachments, workspace)?;
23✔
1139
        apply_mounts(&mut partial, mount, workspace, merged_config)?;
23✔
1140
        apply_reasoning(&mut partial, reasoning.as_ref(), *no_reasoning);
23✔
1141

1142
        for kv in parameters.clone() {
23✔
1143
            partial.assistant.model.parameters.assign(kv)?;
×
1144
        }
1145

1146
        if *hide_reasoning {
23✔
1147
            partial.style.reasoning.display = Some(ReasoningDisplayConfig::Hidden);
×
1148
        }
23✔
1149

1150
        if *hide_tool_calls {
23✔
1151
            partial.style.tool_call.show = Some(false);
×
1152
        }
23✔
1153

1154
        Ok(partial)
23✔
1155
    }
23✔
1156

1157
    fn apply_conversation_config(
4✔
1158
        &self,
4✔
1159
        workspace: &Workspace,
4✔
1160
        partial: PartialAppConfig,
4✔
1161
        _: Option<&PartialAppConfig>,
4✔
1162
        handle: &ConversationHandle,
4✔
1163
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
4✔
1164
        let config = workspace.events(handle)?.config().map(|c| c.to_partial())?;
4✔
1165

1166
        load_partial(partial, config).map_err(Into::into)
4✔
1167
    }
4✔
1168
}
1169

1170
/// Build the sorted list of system prompt sections from assistant config.
1171
///
1172
/// Used by both [`build_thread`] and [`LlmInquiryBackend`] construction to
1173
/// ensure the inquiry backend sees the same sections as the main thread.
1174
///
1175
/// [`LlmInquiryBackend`]: crate::cmd::query::tool::inquiry::LlmInquiryBackend
1176
pub(super) fn build_sections(assistant: &AssistantConfig, has_tools: bool) -> Vec<SectionConfig> {
100✔
1177
    let mut sections: Vec<_> = assistant.system_prompt_sections.clone();
100✔
1178
    sections.extend(
100✔
1179
        assistant
100✔
1180
            .instructions
100✔
1181
            .iter()
100✔
1182
            .map(InstructionsConfig::to_section),
100✔
1183
    );
1184

1185
    if has_tools {
100✔
1186
        let tool_section = InstructionsConfig::default()
42✔
1187
            .with_title("Tool Usage")
42✔
1188
            .with_description("How to leverage the tools available to you.".to_string())
42✔
1189
            .with_item("Use all the tools available to you to give the best possible answer.")
42✔
1190
            .with_item("Verify the tool name, description and parameters are correct.")
42✔
1191
            .with_item(
42✔
1192
                "Even if you've reasoned yourself towards a solution, use any available tool to \
42✔
1193
                 verify your answer.",
42✔
1194
            )
42✔
1195
            .to_section();
42✔
1196

42✔
1197
        sections.push(tool_section);
42✔
1198
    }
58✔
1199

1200
    sections.sort_by_key(|s| s.position);
100✔
1201
    sections
100✔
1202
}
100✔
1203

1204
fn build_thread(
63✔
1205
    events: ConversationStream,
63✔
1206
    attachments: Vec<Attachment>,
63✔
1207
    assistant: &AssistantConfig,
63✔
1208
    has_tools: bool,
63✔
1209
) -> Result<Thread> {
63✔
1210
    let sections = build_sections(assistant, has_tools);
63✔
1211

1212
    let mut thread_builder = ThreadBuilder::default()
63✔
1213
        .with_sections(sections)
63✔
1214
        .with_attachments(attachments)
63✔
1215
        .with_events(events);
63✔
1216

1217
    if let Some(system_prompt) = assistant.system_prompt.clone() {
63✔
1218
        thread_builder = thread_builder.with_system_prompt(system_prompt);
63✔
1219
    }
63✔
1220

1221
    Ok(thread_builder.build()?)
63✔
1222
}
63✔
1223

1224
/// Apply the CLI model configuration to the partial configuration.
1225
fn apply_model(partial: &mut PartialAppConfig, model: Option<&str>, _: Option<&PartialAppConfig>) {
23✔
1226
    let Some(id) = model else { return };
23✔
1227

1228
    partial.assistant.model.id = id.into();
6✔
1229
}
23✔
1230

1231
/// Apply the CLI editor configuration to the partial configuration.
1232
fn apply_editor(partial: &mut PartialAppConfig, editor: Option<Option<&Editor>>, no_edit: bool) {
23✔
1233
    let Some(Some(editor)) = editor else {
×
1234
        return;
23✔
1235
    };
1236

1237
    match (no_edit, editor) {
×
1238
        (true, _) | (_, Editor::Disabled) => {
×
1239
            partial.editor.cmd = None;
×
1240
            partial.editor.envs = None;
×
1241
        }
×
1242
        (_, Editor::Default) => {}
×
1243
        (_, Editor::Command(cmd)) => partial.editor.cmd = Some(cmd.clone()),
×
1244
    }
1245
}
23✔
1246

1247
fn apply_enable_tools(
23✔
1248
    partial: &mut PartialAppConfig,
23✔
1249
    directives: &ToolDirectives,
23✔
1250
    merged_config: Option<&PartialAppConfig>,
23✔
1251
) -> BoxedResult<()> {
23✔
1252
    if directives.is_empty() {
23✔
1253
        return Ok(());
11✔
1254
    }
12✔
1255

1256
    let existing_tools = merged_config.map_or(&partial.conversation.tools.tools, |v| {
12✔
1257
        &v.conversation.tools.tools
×
1258
    });
×
1259

1260
    // Validate all named tools exist.
1261
    let missing: HashSet<_> = directives
12✔
1262
        .iter()
12✔
1263
        .filter_map(ToolDirective::as_single)
12✔
1264
        .filter(|name| !existing_tools.contains_key(*name))
12✔
1265
        .collect();
12✔
1266

1267
    if missing.len() == 1 {
12✔
1268
        return Err(ToolError::NotFound {
×
1269
            name: missing.iter().next().unwrap().to_string(),
×
1270
        }
×
1271
        .into());
×
1272
    } else if !missing.is_empty() {
12✔
1273
        return Err(ToolError::NotFoundN {
×
1274
            names: missing.into_iter().map(ToString::to_string).collect(),
×
1275
        }
×
1276
        .into());
×
1277
    }
12✔
1278

1279
    // Validate that core tools are not disabled by name.
1280
    for d in directives.iter() {
19✔
1281
        if let ToolDirective::Disable(name) = d
19✔
1282
            && let Some(tool) = partial.conversation.tools.tools.get(name.as_str())
3✔
1283
            && tool.enable.is_some_and(Enable::is_always)
3✔
1284
        {
1285
            return Err(format!("Tool '{name}' is a system tool and cannot be disabled").into());
×
1286
        }
19✔
1287
    }
1288

1289
    // Apply directives left-to-right.
1290
    for d in directives.iter() {
19✔
1291
        match d {
19✔
1292
            ToolDirective::EnableAll => {
1293
                partial
5✔
1294
                    .conversation
5✔
1295
                    .tools
5✔
1296
                    .tools
5✔
1297
                    .iter_mut()
5✔
1298
                    .filter(|(_, v)| !v.enable.is_some_and(Enable::is_explicit))
25✔
1299
                    .for_each(|(_, v)| v.enable = Some(Enable::On));
21✔
1300
            }
1301
            ToolDirective::DisableAll => {
1302
                partial
4✔
1303
                    .conversation
4✔
1304
                    .tools
4✔
1305
                    .tools
4✔
1306
                    .iter_mut()
4✔
1307
                    .filter(|(_, v)| !v.enable.is_some_and(Enable::is_always))
20✔
1308
                    .for_each(|(_, v)| v.enable = Some(Enable::Off));
17✔
1309
            }
1310
            ToolDirective::Enable(name) => {
7✔
1311
                if let Some(tool) = partial.conversation.tools.tools.get_mut(name.as_str()) {
7✔
1312
                    tool.enable = Some(Enable::On);
7✔
1313
                }
7✔
1314
            }
1315
            ToolDirective::Disable(name) => {
3✔
1316
                if let Some(tool) = partial.conversation.tools.tools.get_mut(name.as_str()) {
3✔
1317
                    tool.enable = Some(Enable::Off);
3✔
1318
                }
3✔
1319
            }
1320
        }
1321
    }
1322

1323
    Ok(())
12✔
1324
}
23✔
1325

1326
/// Apply the CLI tool use configuration to the partial configuration.
1327
///
1328
/// NOTE: This has to run *after* `apply_enable_tools` because it will return an
1329
/// error if the tool of choice is not enabled.
1330
fn apply_tool_use(
23✔
1331
    partial: &mut PartialAppConfig,
23✔
1332
    tool_choice: Option<Option<&str>>,
23✔
1333
    no_tool_choice: bool,
23✔
1334
) -> BoxedResult<()> {
23✔
1335
    if no_tool_choice || matches!(tool_choice, Some(Some("false"))) {
23✔
1336
        partial.assistant.tool_choice = Some(ToolChoice::None);
×
1337
        return Ok(());
×
1338
    }
23✔
1339

1340
    let Some(tool) = tool_choice else {
23✔
1341
        return Ok(());
23✔
1342
    };
1343

1344
    partial.assistant.tool_choice = match tool {
×
1345
        None | Some("true") => Some(ToolChoice::Required),
×
1346
        Some(v) => {
×
1347
            if !partial
×
1348
                .conversation
×
1349
                .tools
×
1350
                .tools
×
1351
                .iter()
×
1352
                .filter(|(_, cfg)| cfg.enable.is_some_and(Enable::is_on))
×
1353
                .any(|(name, _)| name == v)
×
1354
            {
1355
                return Err(format!("tool choice '{v}' does not match any enabled tools").into());
×
1356
            }
×
1357

1358
            Some(ToolChoice::Function(v.to_owned()))
×
1359
        }
1360
    };
1361

1362
    Ok(())
×
1363
}
23✔
1364

1365
/// Apply the CLI attachments to the partial configuration.
1366
fn apply_attachments(
23✔
1367
    partial: &mut PartialAppConfig,
23✔
1368
    attachments: &[AttachmentUrlOrPath],
23✔
1369
    workspace: Option<&Workspace>,
23✔
1370
) -> Result<()> {
23✔
1371
    let root = workspace.map(Workspace::root);
23✔
1372
    let attachments = attachments
23✔
1373
        .iter()
23✔
1374
        .map(|v| v.parse(root))
23✔
1375
        .collect::<Result<Vec<_>>>()?;
23✔
1376

1377
    partial
23✔
1378
        .conversation
23✔
1379
        .attachments
23✔
1380
        .extend(attachments.into_iter().map(Into::into));
23✔
1381

1382
    Ok(())
23✔
1383
}
23✔
1384

1385
/// Apply the CLI reasoning configuration to the partial configuration.
1386
fn apply_reasoning(
23✔
1387
    partial: &mut PartialAppConfig,
23✔
1388
    reasoning: Option<&ReasoningConfig>,
23✔
1389
    no_reasoning: bool,
23✔
1390
) {
23✔
1391
    if no_reasoning {
23✔
1392
        partial.assistant.model.parameters.reasoning = Some(PartialReasoningConfig::Off);
×
1393
        return;
×
1394
    }
23✔
1395

1396
    let Some(reasoning) = reasoning else {
23✔
1397
        return;
23✔
1398
    };
1399

1400
    partial.assistant.model.parameters.reasoning = Some(match reasoning {
×
1401
        ReasoningConfig::Off => PartialReasoningConfig::Off,
×
1402
        ReasoningConfig::Auto => PartialReasoningConfig::Auto,
×
1403
        ReasoningConfig::Custom(custom) => PartialCustomReasoningConfig {
×
1404
            effort: Some(custom.effort),
×
1405
            exclude: Some(custom.exclude),
×
1406
        }
×
1407
        .into(),
×
1408
    });
1409
}
23✔
1410

1411
/// A resolved mount and the tools it grants access to (stage 1 planning).
1412
struct MountPlan {
1413
    rule_path: String,
1414
    write: bool,
1415
    /// (tool name, whether its `access.fs` is empty across all layers)
1416
    targets: Vec<(String, bool)>,
1417
}
1418

1419
/// Inject `--mount` access grants into the partial config (stage 1).
1420
///
1421
/// Pure config mutation: one `access.fs` rule per in-scope tool.
1422
/// The symlink is not required to exist yet; it is created later in
1423
/// [`Query::run`].
1424
/// When a tool had no filesystem rules from any layer, a workspace-default rule
1425
/// is also injected so the mount doesn't silently switch the tool to deny-all.
1426
fn apply_mounts(
23✔
1427
    partial: &mut PartialAppConfig,
23✔
1428
    mounts: &[String],
23✔
1429
    workspace: Option<&Workspace>,
23✔
1430
    merged_config: Option<&PartialAppConfig>,
23✔
1431
) -> BoxedResult<()> {
23✔
1432
    if mounts.is_empty() {
23✔
1433
        return Ok(());
23✔
NEW
1434
    }
×
1435

NEW
1436
    let workspace = workspace.ok_or("`--mount` requires a workspace")?;
×
NEW
1437
    let root = workspace.root().to_owned();
×
NEW
1438
    let cwd = current_dir_utf8()?;
×
1439

1440
    // Resolve the tool set and the global enable default from the merged
1441
    // config (the fully-layered view) so a bare mount expands over the tools
1442
    // actually enabled in the resolved config, honoring `*` defaults.
NEW
1443
    let tools_config = merged_config.map_or(&partial.conversation.tools, |v| &v.conversation.tools);
×
NEW
1444
    let default_enable = tools_config.defaults.enable;
×
NEW
1445
    let existing = &tools_config.tools;
×
1446

NEW
1447
    let mut plans = Vec::new();
×
NEW
1448
    for spec in mounts {
×
NEW
1449
        let spec = MountSpec::parse(spec)?;
×
NEW
1450
        let rule_path = spec.resolve_name(&cwd, &root)?.as_str().to_owned();
×
1451

NEW
1452
        let targets = match &spec.tool {
×
NEW
1453
            Some(tool) => vec![(tool.clone(), tool_access_empty(existing, tool))],
×
NEW
1454
            None => existing
×
NEW
1455
                .iter()
×
NEW
1456
                .filter(|(_, cfg)| is_enabled_local(cfg, default_enable))
×
NEW
1457
                .map(|(name, _)| (name.clone(), tool_access_empty(existing, name)))
×
NEW
1458
                .collect(),
×
1459
        };
1460

NEW
1461
        plans.push(MountPlan {
×
NEW
1462
            rule_path,
×
NEW
1463
            write: spec.mode == MountMode::Rw,
×
NEW
1464
            targets,
×
NEW
1465
        });
×
1466
    }
1467

NEW
1468
    for plan in plans {
×
NEW
1469
        for (tool, access_empty) in plan.targets {
×
NEW
1470
            let cfg = partial.conversation.tools.tools.entry(tool).or_default();
×
NEW
1471
            let access = cfg.access.get_or_insert_with(PartialAccessConfig::default);
×
1472

NEW
1473
            let already_present = access
×
NEW
1474
                .fs
×
NEW
1475
                .iter()
×
NEW
1476
                .any(|rule| rule.path.as_deref() == Some(plan.rule_path.as_str()));
×
NEW
1477
            if already_present {
×
NEW
1478
                continue;
×
NEW
1479
            }
×
1480

NEW
1481
            if access_empty && access.fs.is_empty() {
×
NEW
1482
                access.fs.push(workspace_default_partial_rule());
×
NEW
1483
            }
×
1484

NEW
1485
            access
×
NEW
1486
                .fs
×
NEW
1487
                .push(mount_partial_rule(&plan.rule_path, plan.write));
×
1488
        }
1489
    }
1490

NEW
1491
    Ok(())
×
1492
}
23✔
1493

1494
/// Create the symlinks and seed the approval store for `--mount` flags (stage
1495
/// 2).
1496
fn create_mount_effects(
2✔
1497
    mounts: &[String],
2✔
1498
    workspace: &Workspace,
2✔
1499
    fs_backend: Option<&jp_storage::backend::FsStorageBackend>,
2✔
1500
    now: chrono::DateTime<chrono::Utc>,
2✔
1501
) -> Result<()> {
2✔
1502
    if mounts.is_empty() {
2✔
1503
        return Ok(());
2✔
NEW
1504
    }
×
1505

NEW
1506
    let root = workspace.root().to_owned();
×
NEW
1507
    let root_canonical = root.canonicalize_utf8().unwrap_or_else(|_| root.clone());
×
NEW
1508
    let cwd = current_dir_utf8().map_err(|e| Error::CliConfig(e.to_string()))?;
×
1509

NEW
1510
    let approvals_path = approval_store_path(fs_backend);
×
NEW
1511
    let mut store = approvals_path
×
NEW
1512
        .as_deref()
×
NEW
1513
        .map(ApprovalStore::load)
×
NEW
1514
        .unwrap_or_default();
×
1515

NEW
1516
    let mut rules = Vec::new();
×
NEW
1517
    for spec in mounts {
×
NEW
1518
        let spec = MountSpec::parse(spec).map_err(|e| Error::CliConfig(e.to_string()))?;
×
NEW
1519
        let rule_path = spec
×
NEW
1520
            .resolve_name(&cwd, &root)
×
NEW
1521
            .map_err(|e| Error::CliConfig(e.to_string()))?;
×
NEW
1522
        let link = root.join(&rule_path);
×
1523

NEW
1524
        let target = expand_tilde(&spec.path, env::var("HOME").ok())
×
NEW
1525
            .unwrap_or_else(|| Utf8PathBuf::from(&spec.path));
×
1526

1527
        // Resolve the target before creating the link so a missing target
1528
        // fails cleanly instead of leaving a broken symlink behind.
NEW
1529
        let canonical = target.canonicalize_utf8().map_err(|e| {
×
NEW
1530
            Error::CliConfig(format!("mount target '{target}' cannot be resolved: {e}"))
×
NEW
1531
        })?;
×
1532

1533
        // An external mount must point outside the workspace. Reject an
1534
        // in-workspace target before any side effect, so a rejected mount
1535
        // leaves no symlink or approval entry behind.
NEW
1536
        if canonical.starts_with(&root_canonical) {
×
NEW
1537
            return Err(Error::CliConfig(format!(
×
NEW
1538
                "mount target '{target}' is inside the workspace; mounts are for external paths"
×
NEW
1539
            )));
×
NEW
1540
        }
×
1541

1542
        // Link to the canonical absolute target so the symlink resolves the
1543
        // same regardless of where it sits and matches the recorded approval
1544
        // (a relative target would resolve against the link's parent instead).
NEW
1545
        create_workspace_symlink(&link, &canonical)?;
×
NEW
1546
        store.record(rule_path.as_str(), canonical, now);
×
NEW
1547
        rules.push(spec.rule(rule_path.as_str()));
×
1548
    }
1549

NEW
1550
    if let Some(path) = approvals_path {
×
NEW
1551
        store.save(&path)?;
×
NEW
1552
    }
×
1553

1554
    // Compile the just-created mounts against the seeded approvals to confirm
1555
    // they resolve to a usable policy, surfacing broken or unapproved targets.
NEW
1556
    let access = AccessConfig { fs: rules };
×
NEW
1557
    let (_, warnings) = compile_policy(&access, &root, |rule_path, candidate| {
×
NEW
1558
        match store.lookup(rule_path, candidate) {
×
NEW
1559
            ApprovalLookup::Approved => ApprovalDecision::Approved,
×
1560
            ApprovalLookup::Retargeted { .. } | ApprovalLookup::Unknown => {
NEW
1561
                ApprovalDecision::Rejected
×
1562
            }
1563
        }
NEW
1564
    })
×
NEW
1565
    .map_err(|e| Error::CliConfig(e.to_string()))?;
×
1566

NEW
1567
    for warning in warnings {
×
NEW
1568
        warn!("{warning}");
×
1569
    }
1570

NEW
1571
    Ok(())
×
1572
}
2✔
1573

1574
/// Create a workspace symlink at `link` pointing to `target`.
1575
///
1576
/// A symlink that already resolves to the same target is a no-op; one resolving
1577
/// elsewhere, or a non-symlink at `link`, is an error.
NEW
1578
fn create_workspace_symlink(link: &Utf8Path, target: &Utf8Path) -> Result<()> {
×
NEW
1579
    if link.is_symlink() {
×
1580
        // Compare resolved targets rather than the raw link text, so a relative
1581
        // and an absolute link to the same place are treated as identical.
NEW
1582
        let same = link
×
NEW
1583
            .canonicalize_utf8()
×
NEW
1584
            .ok()
×
NEW
1585
            .zip(target.canonicalize_utf8().ok())
×
NEW
1586
            .is_some_and(|(existing, wanted)| existing == wanted);
×
NEW
1587
        if same {
×
NEW
1588
            return Ok(());
×
NEW
1589
        }
×
NEW
1590
        return Err(Error::CliConfig(format!(
×
NEW
1591
            "mount '{link}' already exists as a symlink pointing elsewhere"
×
NEW
1592
        )));
×
NEW
1593
    }
×
1594

NEW
1595
    if link.exists() {
×
NEW
1596
        return Err(Error::CliConfig(format!(
×
NEW
1597
            "cannot create mount: '{link}' already exists and is not a symlink"
×
NEW
1598
        )));
×
NEW
1599
    }
×
1600

NEW
1601
    if let Some(parent) = link.parent() {
×
NEW
1602
        fs::create_dir_all(parent)?;
×
NEW
1603
    }
×
1604

1605
    #[cfg(unix)]
NEW
1606
    std::os::unix::fs::symlink(target.as_std_path(), link.as_std_path())?;
×
1607

1608
    #[cfg(windows)]
1609
    if target.is_dir() {
1610
        std::os::windows::fs::symlink_dir(target.as_std_path(), link.as_std_path())?;
1611
    } else {
1612
        std::os::windows::fs::symlink_file(target.as_std_path(), link.as_std_path())?;
1613
    }
1614

NEW
1615
    Ok(())
×
NEW
1616
}
×
1617

1618
/// Resolve the path to the user-local approval store, if user storage exists.
NEW
1619
fn approval_store_path(
×
NEW
1620
    fs_backend: Option<&jp_storage::backend::FsStorageBackend>,
×
NEW
1621
) -> Option<Utf8PathBuf> {
×
NEW
1622
    fs_backend
×
NEW
1623
        .and_then(|fs| fs.user_storage_with_path(relative_path::RelativePath::new(APPROVALS_FILE)))
×
NEW
1624
}
×
1625

1626
/// Load the approval store, treating missing/in-memory storage as empty.
NEW
1627
fn load_approval_store(
×
NEW
1628
    fs_backend: Option<&jp_storage::backend::FsStorageBackend>,
×
NEW
1629
) -> ApprovalStore {
×
NEW
1630
    approval_store_path(fs_backend)
×
NEW
1631
        .as_deref()
×
NEW
1632
        .map(ApprovalStore::load)
×
NEW
1633
        .unwrap_or_default()
×
NEW
1634
}
×
1635

NEW
1636
fn current_dir_utf8() -> BoxedResult<Utf8PathBuf> {
×
NEW
1637
    let cwd = env::current_dir()?;
×
NEW
1638
    Utf8PathBuf::from_path_buf(cwd)
×
NEW
1639
        .map_err(|path| format!("current directory is not valid UTF-8: {}", path.display()).into())
×
NEW
1640
}
×
1641

1642
/// Whether a tool's `access.fs` is empty across all merged layers.
NEW
1643
fn tool_access_empty(
×
NEW
1644
    tools: &IndexMap<String, jp_config::conversation::tool::PartialToolConfig>,
×
NEW
1645
    name: &str,
×
NEW
1646
) -> bool {
×
NEW
1647
    tools
×
NEW
1648
        .get(name)
×
NEW
1649
        .and_then(|cfg| cfg.access.as_ref())
×
NEW
1650
        .is_none_or(|access| access.fs.is_empty())
×
NEW
1651
}
×
1652

1653
/// Whether a partial tool config is an enabled local tool.
1654
///
1655
/// The tool's own `enable` takes precedence over the global `*` default; a tool
1656
/// that is `off` or `explicit` after that resolution is not part of a bare
1657
/// mount's scope.
NEW
1658
fn is_enabled_local(
×
NEW
1659
    cfg: &jp_config::conversation::tool::PartialToolConfig,
×
NEW
1660
    default_enable: Option<Enable>,
×
NEW
1661
) -> bool {
×
NEW
1662
    matches!(cfg.source, Some(ToolSource::Local { .. }))
×
NEW
1663
        && !matches!(
×
NEW
1664
            cfg.enable.or(default_enable),
×
1665
            Some(Enable::Off | Enable::Explicit)
1666
        )
NEW
1667
}
×
1668

1669
/// The workspace-default rule injected to preserve a tool's prior implicit
1670
/// workspace access.
NEW
1671
fn workspace_default_partial_rule() -> PartialFsRuleConfig {
×
NEW
1672
    PartialFsRuleConfig {
×
NEW
1673
        path: Some(".".to_owned()),
×
NEW
1674
        read: Some(true),
×
NEW
1675
        write: Some(true),
×
NEW
1676
        ..PartialFsRuleConfig::default()
×
NEW
1677
    }
×
NEW
1678
}
×
1679

1680
/// The `access.fs` rule a mount injects.
NEW
1681
fn mount_partial_rule(rule_path: &str, write: bool) -> PartialFsRuleConfig {
×
NEW
1682
    PartialFsRuleConfig {
×
NEW
1683
        path: Some(rule_path.to_owned()),
×
NEW
1684
        external: Some(true),
×
NEW
1685
        read: Some(true),
×
NEW
1686
        write: Some(write),
×
NEW
1687
        ..PartialFsRuleConfig::default()
×
NEW
1688
    }
×
NEW
1689
}
×
1690

1691
/// Set the terminal title to show the active conversation.
1692
fn set_terminal_title(id: ConversationId, title: Option<&str>) {
×
1693
    let display = match title {
×
1694
        Some(t) => format!("{id}: {t}"),
×
1695
        None => id.to_string(),
×
1696
    };
1697
    jp_term::osc::set_title(display);
×
1698
}
×
1699

1700
/// Parse a schema string as either a concise DSL or raw JSON Schema.
1701
#[expect(clippy::needless_pass_by_value)]
1702
fn parse_schema(s: String) -> Result<schemars::Schema> {
×
1703
    crate::schema::parse_schema_dsl(&s)
×
1704
        .map_err(|e| Error::Schema(e.to_string()))?
×
1705
        .try_into()
×
1706
        .map_err(Into::into)
×
1707
}
×
1708

1709
/// Parse the `--fork` value.
1710
/// Empty string means "all turns", a number means "keep last N turns".
1711
fn parse_fork_turns(s: &str) -> std::result::Result<Option<usize>, String> {
×
1712
    if s.is_empty() {
×
1713
        return Ok(None);
×
1714
    }
×
1715
    s.parse::<usize>()
×
1716
        .map(Some)
×
1717
        .map_err(|_| format!("expected a positive integer, got '{s}'"))
×
1718
}
×
1719

1720
fn string_or_path(s: &str) -> Result<String> {
×
1721
    if let Some(s) = s
×
1722
        .strip_prefix(PATH_STRING_PREFIX)
×
1723
        .and_then(|s| expand_tilde(s, env::var("HOME").ok()))
×
1724
    {
1725
        return fs::read_to_string(s).map_err(Into::into);
×
1726
    }
×
1727

1728
    Ok(s.to_owned())
×
1729
}
×
1730

1731
#[cfg(test)]
1732
#[path = "query_tests.rs"]
1733
mod tests;
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