Skip to main content

mermaid_cli/app/
run.rs

1//! The ~30-line main loop.
2//!
3//! Single entry point that composes crossterm events, the reducer,
4//! and the effect runner:
5//!
6//! ```text
7//!   crossterm events ──┐
8//!                      ├── tokio::select! ── Msg ── update(State, Msg) ── (State, Vec<Cmd>) ── EffectRunner::dispatch ──┐
9//!   effect results  ──┤                                                                                                   │
10//!                      │                                                                          ▲                         │
11//!   tick              ──┘                                                                          │                         │
12//!                                                                                                  └─────── Msg back ◄──────┘
13//! ```
14//!
15//! No parallel event loops, no observer callbacks, no polling. One
16//! select!, one reducer call per message, effects dispatched into
17//! structured concurrency per turn.
18
19use std::collections::VecDeque;
20use std::path::PathBuf;
21
22use anyhow::Result;
23use crossterm::event::EventStream;
24use futures::{FutureExt, StreamExt};
25use ratatui::layout::Rect;
26use tokio::time::{Duration, interval};
27
28use crate::app::event_source::{bridge_paste_chunks, coalesce_key_burst_seamed};
29use crate::app::lifecycle::RuntimeLifecycle;
30use crate::app::recorder::{RECORDING_FORMAT_VERSION, Recorder, SessionHeader};
31use crate::app::terminal::TerminalGuard;
32use crate::effect::EffectRunner;
33use crate::providers::ToolRegistry;
34use crate::render::{RenderCache, render};
35use mermaid_domain::Config;
36use mermaid_domain::ConversationHistory;
37use mermaid_domain::{Cmd, Msg, Paste, RuntimeSignal, State, update};
38
39/// Options for `run_interactive_with`. Added so new flags land without
40/// reshuffling positional args.
41///
42/// Not `Debug` because `Recorder` owns a `BufWriter<File>` which isn't
43/// Debug. The bigger picture is that nothing prints these — they're an
44/// argument bundle, not telemetry.
45#[derive(Default)]
46pub struct InteractiveOptions {
47    /// Optional recorder for `--record <file>` JSONL capture.
48    pub recorder: Option<Recorder>,
49    /// Optional conversation to seed the session with (e.g. from
50    /// `--continue` or `--sessions`). When `Some`, the seeded history
51    /// replaces `State::session.conversation` before the first frame.
52    pub seed_conversation: Option<ConversationHistory>,
53}
54
55/// Resolve `user@host` for the status bar, once, at startup.
56///
57/// Lives in the shell rather than beside the `RenderCache` fields it fills:
58/// `src/render` is covered by the layering guard, so an environment read
59/// anywhere under it is impurity in a tree that must stay a pure function of
60/// `State`. Reading here and passing the result down is the whole difference.
61///
62/// `HOSTNAME`/`HOST` and `USER`/`USERNAME` are checked in that order because
63/// the first of each pair is the Unix spelling and the second the Windows one;
64/// the final fallbacks keep the status bar rendering something sane when a
65/// stripped environment provides neither.
66fn host_identity() -> (String, String) {
67    let hostname = std::env::var("HOSTNAME")
68        .or_else(|_| std::env::var("HOST"))
69        .unwrap_or_else(|_| "localhost".to_string());
70    let username = std::env::var("USER")
71        .or_else(|_| std::env::var("USERNAME"))
72        .unwrap_or_else(|_| "user".to_string());
73    (hostname, username)
74}
75
76/// Interactive TUI main loop with explicit options. `recorder` (if
77/// provided) appends one JSONL line per reducer input to the file for
78/// debugging / replay.
79///
80/// # Errors
81///
82/// Setting up the terminal, opening the recorder when one is requested, and a
83/// failure in the main loop or in the shutdown that follows it. A model or
84/// tool that fails mid-session is not among them: those surface in the
85/// transcript and the loop continues, which is what makes a session survivable.
86#[expect(
87    clippy::too_many_lines,
88    reason = "predates the lint; see .github/baselines/expect_budget.txt"
89)]
90pub async fn run_interactive_with(
91    mut config: Config,
92    cwd: PathBuf,
93    model_id: String,
94    mut opts: InteractiveOptions,
95) -> Result<()> {
96    // One startup clock read, shared by `State::new` and the recording
97    // header: replay seeds `State::new` with the recorded value and gets the
98    // same initial conversation id/title.
99    let startup_now = chrono::Local::now();
100    // Fold enabled plugins' MCP servers + agent types into the merged config
101    // BEFORE anything consumes it (State::new seeds server rows, the
102    // recording header captures the merged config — replay-faithful, and the
103    // provider factory + tool registry see the same view).
104    let plugin_assets = crate::app::plugin_assets::load();
105    let plugin_warnings = crate::app::plugin_assets::apply(&mut config, &plugin_assets);
106    let mut state = State::new(
107        config.clone(),
108        cwd.clone(),
109        model_id.clone(),
110        startup_now,
111        std::env::temp_dir(),
112    );
113    let seed = opts.seed_conversation.take();
114    if let Some(r) = opts.recorder.as_mut() {
115        // The header makes a recording self-contained: `--replay` rebuilds
116        // the initial State from it (config, model, cwd, seed) without
117        // reading this machine's live config. Written before the first Msg
118        // so even a crashed session leaves a parseable log.
119        r.record_header(&SessionHeader {
120            format: RECORDING_FORMAT_VERSION,
121            ts: startup_now,
122            model_id: model_id.clone(),
123            cwd: cwd.clone(),
124            config: config.clone(),
125            seed_conversation: seed.clone(),
126        })?;
127    }
128    if let Some(history) = seed {
129        // `--continue` / `--resume` seed — shared with `--replay` via
130        // `State::seed_conversation` so both build the same starting state.
131        state.seed_conversation(history);
132    }
133    state
134        .ui
135        .pending_msgs
136        .push_back(Msg::SessionProvenanceResolved(
137            crate::session::probe_session_provenance(&cwd),
138        ));
139    // NO_COLOR (https://no-color.org): present and non-empty disables all
140    // color. Read once here — the reducer never touches the environment; the
141    // render layer resolves `Theme::plain()` off this flag.
142    state.ui.no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
143    // Skills load once at startup (authored artifacts, no watcher); the config
144    // watcher below keeps only instructions/memory fresh.
145    state.skills = crate::app::skills::load(&cwd);
146    // Plugin prompt commands: same restart-to-refresh policy as skills.
147    state.plugin_commands = plugin_assets.commands;
148    for warning in plugin_warnings {
149        state
150            .ui
151            .pending_msgs
152            .push_back(Msg::TransientStatus { text: warning });
153    }
154    let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
155    let tools = ToolRegistry::build(
156        &config,
157        crate::providers::TuiMode::Interactive,
158        providers.clone(),
159    );
160    if let Some(capabilities) = tools.web_capabilities()
161        && let Some(text) = web_capabilities_notice(&config, capabilities)
162    {
163        state
164            .ui
165            .pending_msgs
166            .push_back(Msg::TransientStatus { text });
167    }
168    let (runner, mut msg_rx) = EffectRunner::pair_from(cwd.clone(), providers, tools);
169    // Interactive TUI: enable inline approval prompts so `ask` mode (and Auto
170    // escalations) pause and prompt instead of erroring out, and inline
171    // `ask_user_question` prompts so the model can ask the user structured
172    // questions mid-run instead of proceeding without them.
173    let mut runner = runner
174        .with_interactive_approvals()
175        .with_interactive_questions();
176    // Keep instructions/memory fresh via the background config watcher (#45):
177    // it emits Msg::InstructionsChanged/MemoryChanged on change, so the reducer
178    // reads them as injected data and never does the refresh I/O inline.
179    runner.spawn_config_watcher(cwd.clone(), config.memory.clone());
180    let mut terminal = Some(TerminalGuard::setup()?);
181    let (hostname, username) = host_identity();
182    let mut rstate = RenderCache::new(hostname, username);
183    // `Option` because the $EDITOR compose round-trip must DROP the stream
184    // (its reader thread holds crossterm's internal reader mutex) before
185    // suspending, and build a fresh one after — same lifecycle dance as
186    // `terminal` above.
187    let mut events = Some(EventStream::new());
188    let mut lifecycle = RuntimeLifecycle::new();
189    let mut tick = interval(Duration::from_millis(16));
190    let mut recorder = opts.recorder;
191
192    // Boot effects: MCP server init (if configured). Instructions/memory are
193    // loaded by the config watcher started above (#45), not here.
194    for cmd in bootstrap_cmds(&config, &state.session.conversation.id) {
195        runner.dispatch(cmd);
196    }
197    // A resumed session may carry an in-flight checklist; hand it to the
198    // TaskBroker (tool-side truth) so the first task tool call of the new
199    // process starts from the restored list instead of an empty one.
200    if !state.session.conversation.tasks.tasks.is_empty() {
201        runner.dispatch(mermaid_domain::Cmd::SyncTaskStore(
202            state.session.conversation.tasks.clone(),
203        ));
204    }
205
206    // Which `select!` arm fired. Terminal events are handled *after* the
207    // select! returns so the paste-coalescing drain can borrow `events`
208    // again without tripping the borrow checker.
209    //
210    // `Msg` is the large variant, but this enum lives on the stack for one
211    // loop iteration and `Msg` is passed by value everywhere already —
212    // boxing it would add a per-event heap alloc on the hot input path.
213    #[expect(clippy::large_enum_variant)]
214    enum Sel {
215        Msg(Option<Msg>),
216        Term(Option<Result<crossterm::event::Event, std::io::Error>>),
217    }
218
219    // Msgs produced ahead of time — e.g. a non-paste event drained while
220    // coalescing a key burst. Processed before pulling the next event.
221    let mut pending_msgs: VecDeque<Msg> = VecDeque::new();
222
223    // Main loop. A fatal error inside the loop is captured here and returned
224    // AFTER the orderly-shutdown path below, so a draw failure can't skip MCP
225    // child cleanup / pending-save drains (the terminal is still restored by
226    // `TerminalGuard::Drop` regardless).
227    let mut exit_result: Result<()> = Ok(());
228    // Last-seen `full_redraw_seq`. When the reducer bumps it (shell command
229    // finished, Ctrl+L), `Terminal::clear()` resets ratatui's back buffer so
230    // the next draw repaints every cell — the only way to overwrite bytes
231    // some other process wrote directly to the tty (ghost cells).
232    let mut seen_redraw_seq = state.ui.full_redraw_seq;
233    loop {
234        // Render the current state. ratatui's draw closure captures
235        // &state, so we don't thread &mut state through the renderer.
236        {
237            let term = terminal
238                .as_mut()
239                .expect("terminal guard is alive while the render loop runs")
240                .inner_mut();
241            if state.ui.full_redraw_seq != seen_redraw_seq {
242                seen_redraw_seq = state.ui.full_redraw_seq;
243                // NOT `Terminal::clear()`: it snapshots the cursor with an
244                // ESC[6n round-trip, and the reply never arrives — the
245                // `EventStream` reader thread is parked holding crossterm's
246                // internal reader mutex and swallows it — so the query dies
247                // fatally after crossterm's 2s deadline. `resize()` to the
248                // current size performs the same full clear + back-buffer
249                // reset for a Fullscreen viewport without querying the tty.
250                let repaint = term
251                    .size()
252                    .and_then(|size| term.resize(Rect::new(0, 0, size.width, size.height)));
253                if let Err(err) = repaint {
254                    exit_result = Err(err.into());
255                    break;
256                }
257            }
258            if let Err(err) = term.draw(|f| render(&state, &mut rstate, f)) {
259                exit_result = Err(err.into());
260                break;
261            }
262        }
263
264        // Drain any msgs queued by a prior burst-coalesce before blocking
265        // on the next event.
266        let msg = if let Some(queued) = pending_msgs.pop_front() {
267            Some(queued)
268        } else {
269            let selected = tokio::select! {
270                // Fair (unbiased) polling. With `biased;`, the hot `msg_rx`
271                // arm would always win under sustained streaming and starve
272                // terminal input + OS signals (#112). Fair selection still
273                // drains streaming promptly — it's almost always ready — while
274                // guaranteeing the input/signal/tick arms get serviced too.
275                //
276                // Effect results (streaming chunks, tool output, …).
277                m = msg_rx.recv() => Sel::Msg(m),
278                // Crossterm events. Handled below, outside the select!, so
279                // coalescing can re-borrow `events`.
280                e = events.as_mut().expect("event stream is alive while the loop runs").next() => Sel::Term(e),
281                // OS lifecycle signals. A typed Ctrl+C in raw mode is handled
282                // by the crossterm branch above; this covers SIGINT/SIGTERM/
283                // SIGHUP delivered externally.
284                s = lifecycle.next_msg() => Sel::Msg(s),
285                // Tick — drives elapsed-time displays + self-dismissing status
286                // lines without busy-waiting.
287                _ = tick.tick() => Sel::Msg(Some(Msg::Tick)),
288            };
289
290            match selected {
291                Sel::Msg(m) => m,
292                Sel::Term(Some(Ok(evt))) => {
293                    if let crossterm::event::Event::Mouse(m) = &evt {
294                        use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind as MEK};
295                        let ctrl = m.modifiers.contains(KeyModifiers::CONTROL);
296                        match m.kind {
297                            // F13: Ctrl+Click a chat image tile opens it via
298                            // the system viewer. The screen→image mapping
299                            // lives in ChatState (the render layer).
300                            MEK::Down(MouseButton::Left) if ctrl => rstate
301                                .chat
302                                .find_image_at_screen_pos(m.row)
303                                .map(|target| Msg::OpenImageAt {
304                                    message_index: target.message_index,
305                                    image_index: target.image_index,
306                                    image_number: target.image_number,
307                                }),
308                            // Plain (no-modifier) left drag selects chat text.
309                            // Handled render-side so wheel-scroll + Ctrl+Click
310                            // keep working; on release we copy the selection.
311                            MEK::Down(MouseButton::Left) => {
312                                rstate.chat.begin_selection(m.row, m.column);
313                                None
314                            },
315                            MEK::Drag(MouseButton::Left) => {
316                                rstate.chat.update_selection(m.row, m.column);
317                                None
318                            },
319                            MEK::Up(MouseButton::Left) => {
320                                // A drag only *selects* (the highlight persists);
321                                // copying is an explicit action (Ctrl+Shift+C).
322                                // Auto-copying on release would silently clobber
323                                // the user's clipboard.
324                                None
325                            },
326                            MEK::ScrollUp => Some(Msg::MouseScroll {
327                                delta: mermaid_model::constants::UI_MOUSE_SCROLL_LINES as i16,
328                            }),
329                            MEK::ScrollDown => Some(Msg::MouseScroll {
330                                delta: -(mermaid_model::constants::UI_MOUSE_SCROLL_LINES as i16),
331                            }),
332                            _ => None,
333                        }
334                    } else {
335                        // Non-mouse event. Ctrl+Shift+C copies the current chat
336                        // selection — the explicit copy step after a drag-select.
337                        // Because the app holds the mouse, the terminal has no
338                        // selection of its own and passes the shortcut through.
339                        // The SHIFT bit only arrives when the kitty keyboard
340                        // protocol was negotiated at setup (TerminalGuard); on
341                        // legacy terminals Ctrl+Shift+C is transmitted as the
342                        // identical byte 0x03 as Ctrl+C — physically
343                        // indistinguishable — so there it falls through to the
344                        // reducer's Ctrl+C handling (press-twice-to-exit keeps
345                        // a stray copy-chord harmless).
346                        if let crossterm::event::Event::Key(k) = &evt
347                            && k.kind == crossterm::event::KeyEventKind::Press
348                            && k.modifiers
349                                .contains(crossterm::event::KeyModifiers::CONTROL)
350                            && k.modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
351                            && matches!(k.code, crossterm::event::KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
352                        {
353                            // Route the copy through the reducer (#18): the
354                            // selection lives in the render layer, but emitting a
355                            // Msg keeps the clipboard side effect recorded +
356                            // replayable instead of dispatched out-of-band.
357                            rstate
358                                .chat
359                                .selected_text()
360                                .filter(|t| !t.is_empty())
361                                .map(Msg::CopySelection)
362                        } else {
363                            // Coalesce a paste burst (crossterm 0.29 doesn't
364                            // deliver Event::Paste on the Windows console — a
365                            // paste arrives as a flood of Char/Enter key events).
366                            // The drain pulls every immediately-available event
367                            // so the whole block lands as one atomic Msg::Paste.
368                            let stream = events
369                                .as_mut()
370                                .expect("event stream is alive while the loop runs");
371                            let (mut primary, mut trailing, ends_with_cr) =
372                                coalesce_key_burst_seamed(evt, || {
373                                    stream.next().now_or_never().flatten().and_then(|r| r.ok())
374                                });
375                            // A paste-shaped burst that stopped on plain quiet
376                            // (no deliberate trailing event) may just be the
377                            // first chunk ConPTY delivered — bridge the gap so
378                            // a chunk boundary right after an Enter never
379                            // submits half a paste (#351). The fold state
380                            // crosses the seam with it, so a CRLF pair split
381                            // at the gap stays one newline.
382                            if trailing.is_empty()
383                                && let Some(Msg::Paste(Paste::Text(text))) = &mut primary
384                            {
385                                trailing = bridge_paste_chunks(text, ends_with_cr, stream).await;
386                            }
387                            for queued in trailing {
388                                pending_msgs.push_back(queued);
389                            }
390                            primary
391                        }
392                    }
393                },
394                Sel::Term(Some(Err(error))) => {
395                    tracing::warn!(error = %error, "terminal event stream failed");
396                    None
397                },
398                Sel::Term(None) => Some(Msg::RuntimeSignal(RuntimeSignal::Hangup)),
399            }
400        };
401
402        let Some(msg) = msg else { continue };
403
404        // Inject the wall clock as data (Cause 3): one stamp per tick, shared
405        // by the recording and the reducer. The recorded `ts` IS the
406        // `state.now` this Msg was reduced under, so `--replay` folds the
407        // same log by stamping each entry's `ts` here and recomputes the
408        // exact same states.
409        let now = chrono::Local::now();
410
411        // Optional recording: one JSONL line per Msg, before the
412        // reducer runs so the log captures even no-op inputs.
413        if let Some(r) = recorder.as_mut()
414            && let Err(err) = r.record_msg(now, &msg)
415        {
416            tracing::warn!(error = %err, "recorder: failed to record message; --replay may be non-deterministic");
417        }
418
419        state.now = now;
420        let (new_state, cmds) = update(state, msg);
421        state = new_state;
422        // `ComposeInEditor` is run-loop-owned (it suspends the terminal +
423        // event stream, which only this loop holds); everything else goes to
424        // the effect runner. At most one compose per reducer step by
425        // construction (single Ctrl+O / /editor arm).
426        let mut compose_draft: Option<String> = None;
427        for cmd in cmds {
428            if let Cmd::ComposeInEditor { text } = cmd {
429                compose_draft = Some(text);
430            } else {
431                runner.dispatch(cmd);
432            }
433        }
434        if let Some(draft) = compose_draft {
435            match crate::app::editor::compose_in_editor(&mut terminal, &mut events, draft).await {
436                // Through pending_msgs, so the result flows through the
437                // recorder like any input — --replay never launches an editor.
438                Ok(msg) => pending_msgs.push_back(msg),
439                Err(err) => {
440                    exit_result = Err(err);
441                    break;
442                },
443            }
444        }
445
446        if state.should_exit {
447            break;
448        }
449    }
450
451    // Seal the recording with a fingerprint of the final session, so a
452    // future `--replay` can verify its fold reproduces what this live
453    // session actually saw — not merely that the fold is self-consistent.
454    // (Wall-clock read is fine here: we're outside the reducer.)
455    if let Some(r) = recorder.as_mut()
456        && let Err(err) = r.record_trailer(chrono::Local::now(), &state.session)
457    {
458        tracing::warn!(error = %err, "recorder: failed to write replay trailer");
459    }
460
461    // Restore the user's terminal before async shutdown. Shutdown can
462    // wait on pending saves / cancelled scopes for a bounded period;
463    // keeping raw mode + mouse capture alive during that wait makes
464    // Ctrl+C feel ignored and can leak mouse escape sequences into
465    // the shell if the user keeps interacting.
466    drop(events);
467    if let Some(mut terminal) = terminal.take() {
468        terminal.restore_now();
469    }
470
471    // Orderly shutdown — wait for any pending saves / scope cleanup. Runs even
472    // when the loop broke on a draw error, so MCP children are reaped cleanly.
473    runner.shutdown().await;
474    exit_result
475}
476
477/// Commands dispatched on startup before the first iteration of the
478/// loop. Fires MCP init (if configured) and materializes the session's
479/// scratch directory. Instructions/memory are loaded by the config
480/// watcher (#45), not here.
481fn bootstrap_cmds(config: &Config, session_id: &str) -> Vec<Cmd> {
482    // Instructions/memory load + stay fresh via the config watcher (#45),
483    // started in `run_interactive_with`.
484    let mut cmds = Vec::new();
485    if !config.mcp_servers.is_empty() {
486        cmds.push(Cmd::InitMcpServers(config.mcp_servers.clone()));
487    }
488    // Every session gets a scratch dir — `session_id` is captured AFTER any
489    // `--continue`/`--resume` seed, so a resumed session adopts the dir
490    // keyed by its restored conversation id.
491    cmds.push(Cmd::EnsureScratchpad {
492        session_id: session_id.to_string(),
493    });
494    cmds
495}
496
497/// One startup-visible summary built from the exact capability resolution used
498/// by the registry and subagents. This makes backend/trust routing explicit in
499/// the TUI without re-reading credentials or probing platform viability.
500///
501/// Returns `None` only for the boring case — every capability resolved AND
502/// every one of them terminates on this machine — so a healthy sovereign
503/// startup stays quiet. Silence therefore means "working and local"; anything
504/// else speaks. Availability alone is deliberately NOT the gate: a working
505/// cloud backend is exactly what a user needs told, so gating on viability
506/// would mute the disclosure precisely when traffic is leaving the machine.
507fn web_capabilities_notice(
508    config: &Config,
509    capabilities: &crate::providers::tool::web::WebCapabilities,
510) -> Option<String> {
511    use crate::providers::tool::web::Egress;
512
513    if config.safety.network == mermaid_domain::NetworkPolicy::Deny {
514        return Some(format!(
515            "Web egress disabled by safety.network = \"deny\" (selected fetch backend: {}; selected search backend: {}).",
516            capabilities.fetch.backend, capabilities.search.backend
517        ));
518    }
519
520    let all = [
521        ("fetch", &capabilities.fetch),
522        ("search", &capabilities.search),
523    ];
524    let degraded = all
525        .into_iter()
526        .filter(|(_, status)| !status.available)
527        .collect::<Vec<_>>();
528    let leaves_machine = all
529        .iter()
530        .any(|(_, status)| status.egress == Egress::OffMachine);
531    if degraded.is_empty() && !leaves_machine {
532        return None;
533    }
534
535    // Headline stays one line per capability: backend + availability, and the
536    // trust destination ONLY where it means something. An unavailable backend
537    // routes nowhere, so naming its destination there is noise that also
538    // strands the remediation text mid-sentence.
539    let headline = |name: &str, status: &crate::providers::tool::web::WebCapabilityStatus| {
540        if status.available {
541            format!(
542                "{name}: {} (available; {})",
543                status.backend, status.trust_destination
544            )
545        } else {
546            format!("{name}: {} (unavailable)", status.backend)
547        }
548    };
549
550    // Remediation prose is a paragraph, not a parenthetical — give each
551    // degraded capability its own line below the headline. The marker is a
552    // `-` bullet, not leading whitespace: the transcript renderer re-wraps
553    // system notices word by word (`wrap_text_with_indent`), so an indent is
554    // dropped and the detail lines would be indistinguishable from the
555    // wrapped headline. A glyph is a word, so it survives.
556    let mut notice = format!(
557        "Web capabilities - {}; {}.",
558        headline("fetch", &capabilities.fetch),
559        headline("search", &capabilities.search)
560    );
561    for (name, status) in degraded {
562        let reason = status
563            .reason
564            .as_deref()
565            .map(mermaid_model::utils::redact_secrets)
566            .unwrap_or_else(|| "backend initialization failed".to_string());
567        let reason = reason.split_whitespace().collect::<Vec<_>>().join(" ");
568        let reason = mermaid_model::utils::truncate_middle_bytes(&reason, 240)
569            .split_whitespace()
570            .collect::<Vec<_>>()
571            .join(" ");
572        notice.push_str(&format!("\n- {name}: {reason}"));
573    }
574    Some(notice)
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn bootstrap_always_ensures_the_session_scratchpad() {
583        // Instructions/memory load via the config watcher (#45), not
584        // bootstrap; with no MCP servers configured, only the scratchpad
585        // ensure remains — keyed by the caller's session id.
586        let cmds = bootstrap_cmds(&Config::default(), "sess-1");
587        assert_eq!(cmds.len(), 1);
588        assert!(
589            cmds.iter().any(
590                |c| matches!(c, Cmd::EnsureScratchpad { session_id } if session_id == "sess-1")
591            )
592        );
593    }
594
595    #[test]
596    fn bootstrap_skips_mcp_init_when_no_servers_configured() {
597        let cmds = bootstrap_cmds(&Config::default(), "sess-1");
598        assert!(!cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
599    }
600
601    #[test]
602    fn bootstrap_includes_mcp_init_when_servers_configured() {
603        let mut cfg = Config::default();
604        cfg.mcp_servers.insert(
605            "example".to_string(),
606            mermaid_domain::McpServerConfig {
607                command: "echo".to_string(),
608                args: vec![],
609                env: std::collections::HashMap::new(),
610                ..Default::default()
611            },
612        );
613        let cmds = bootstrap_cmds(&cfg, "sess-1");
614        assert!(cmds.iter().any(|c| matches!(c, Cmd::InitMcpServers(_))));
615    }
616
617    /// Statuses are built by hand rather than via `WebCapabilities::resolve`
618    /// so the notice's formatting is asserted independently of whichever
619    /// backends happen to be viable on the test host.
620    fn capabilities(
621        fetch: crate::providers::tool::web::WebCapabilityStatus,
622        search: crate::providers::tool::web::WebCapabilityStatus,
623    ) -> crate::providers::tool::web::WebCapabilities {
624        crate::providers::tool::web::WebCapabilities::from_statuses_for_test(fetch, search)
625    }
626
627    fn available(
628        backend: &'static str,
629        trust_destination: &'static str,
630        egress: crate::providers::tool::web::Egress,
631    ) -> crate::providers::tool::web::WebCapabilityStatus {
632        crate::providers::tool::web::WebCapabilityStatus {
633            available: true,
634            backend,
635            trust_destination,
636            egress,
637            reason: None,
638        }
639    }
640
641    fn unavailable(
642        backend: &'static str,
643        trust_destination: &'static str,
644        egress: crate::providers::tool::web::Egress,
645        reason: &str,
646    ) -> crate::providers::tool::web::WebCapabilityStatus {
647        crate::providers::tool::web::WebCapabilityStatus {
648            available: false,
649            backend,
650            trust_destination,
651            egress,
652            reason: Some(reason.to_string()),
653        }
654    }
655
656    /// The two sovereign defaults, spelled once: fetch straight off this
657    /// machine, search via the locally managed SearXNG process.
658    fn local_fetch() -> crate::providers::tool::web::WebCapabilityStatus {
659        available(
660            "native",
661            "direct from this machine",
662            crate::providers::tool::web::Egress::OnMachine,
663        )
664    }
665
666    fn local_search() -> crate::providers::tool::web::WebCapabilityStatus {
667        available(
668            "managed_searxng",
669            "local managed process",
670            crate::providers::tool::web::Egress::OnMachine,
671        )
672    }
673
674    #[test]
675    fn web_capability_notice_stays_silent_when_everything_resolved_and_local() {
676        let config = Config::default();
677        let capabilities = capabilities(local_fetch(), local_search());
678        assert_eq!(web_capabilities_notice(&config, &capabilities), None);
679    }
680
681    /// The regression this gate exists to prevent: a WORKING cloud backend is
682    /// the case a sovereignty-focused tool most needs to disclose, so
683    /// viability alone must never buy silence.
684    #[test]
685    fn web_capability_notice_discloses_working_cloud_egress() {
686        let config = Config::default();
687        let capabilities = capabilities(
688            local_fetch(),
689            available(
690                "ollama_cloud",
691                "Ollama Cloud",
692                crate::providers::tool::web::Egress::OffMachine,
693            ),
694        );
695        let notice =
696            web_capabilities_notice(&config, &capabilities).expect("cloud egress must disclose");
697        assert!(
698            notice.contains("search: ollama_cloud (available; Ollama Cloud)"),
699            "{notice}"
700        );
701        // Nothing is broken, so nothing earns a remediation line.
702        assert!(!notice.contains('\n'), "{notice}");
703    }
704
705    /// An operator-supplied SearXNG URL cannot be proven to be loopback, so it
706    /// discloses like any other off-machine destination.
707    #[test]
708    fn web_capability_notice_discloses_configured_searxng_endpoint() {
709        let config = Config::default();
710        let capabilities = capabilities(
711            local_fetch(),
712            available(
713                "searxng",
714                "configured SearXNG instance",
715                crate::providers::tool::web::Egress::OffMachine,
716            ),
717        );
718        let notice =
719            web_capabilities_notice(&config, &capabilities).expect("configured endpoint discloses");
720        assert!(notice.contains("configured SearXNG instance"), "{notice}");
721    }
722
723    #[test]
724    fn web_capability_notice_gives_every_degraded_capability_its_own_line() {
725        let config = Config::default();
726        let capabilities = capabilities(
727            unavailable(
728                "native",
729                "direct from this machine",
730                crate::providers::tool::web::Egress::OnMachine,
731                "TLS backend failed to initialize",
732            ),
733            unavailable(
734                "managed_searxng",
735                "local managed process",
736                crate::providers::tool::web::Egress::OnMachine,
737                "no sovereign SearXNG bundle is available for this platform",
738            ),
739        );
740        let notice = web_capabilities_notice(&config, &capabilities).expect("both degraded");
741        let lines = notice.lines().collect::<Vec<_>>();
742        assert_eq!(lines.len(), 3, "{notice}");
743        assert!(lines[1].starts_with("- fetch: TLS backend"), "{notice}");
744        assert!(lines[2].starts_with("- search: no sovereign"), "{notice}");
745    }
746
747    #[test]
748    fn web_capability_notice_discloses_shared_backend_and_trust_routing() {
749        let config = Config::default();
750        let capabilities = capabilities(
751            local_fetch(),
752            unavailable(
753                "managed_searxng",
754                "local managed process",
755                crate::providers::tool::web::Egress::OnMachine,
756                "no sovereign SearXNG bundle is available for this platform",
757            ),
758        );
759        let notice = web_capabilities_notice(&config, &capabilities).expect("degraded search");
760        // The healthy capability still discloses where its traffic goes.
761        assert!(notice.contains("fetch: native (available"), "{notice}");
762        assert!(notice.contains("direct from this machine"), "{notice}");
763        assert!(
764            notice.contains("search: managed_searxng (unavailable)"),
765            "{notice}"
766        );
767    }
768
769    #[test]
770    fn web_capability_notice_moves_remediation_off_the_headline() {
771        let config = Config::default();
772        let capabilities = capabilities(
773            local_fetch(),
774            unavailable(
775                "managed_searxng",
776                "local managed process",
777                crate::providers::tool::web::Egress::OnMachine,
778                "no sovereign SearXNG bundle is available for this platform (windows/x86_64).\n  Configure `[web] search_backend = \"ollama\"`.",
779            ),
780        );
781        let notice = web_capabilities_notice(&config, &capabilities).expect("degraded search");
782        let (headline, detail) = notice.split_once('\n').expect("detail line");
783        // The unavailable backend routes nowhere, so its trust destination is
784        // not named — and the paragraph never lands mid-parenthetical.
785        assert!(!headline.contains("local managed process"), "{headline}");
786        assert!(!headline.contains("SearXNG bundle"), "{headline}");
787        assert_eq!(
788            detail,
789            "- search: no sovereign SearXNG bundle is available for this platform (windows/x86_64). Configure `[web] search_backend = \"ollama\"`."
790        );
791    }
792
793    #[test]
794    fn web_capability_notice_honors_global_network_denial() {
795        let mut config = Config::default();
796        config.safety.network = mermaid_domain::NetworkPolicy::Deny;
797        // Denial reports regardless of viability or locality — both backends
798        // resolve here, and both stay on this machine.
799        let capabilities = capabilities(local_fetch(), local_search());
800        let notice = web_capabilities_notice(&config, &capabilities).expect("denial always shows");
801        assert!(notice.contains("Web egress disabled"), "{notice}");
802        assert!(notice.contains("fetch backend: native"), "{notice}");
803        assert!(
804            notice.contains("search backend: managed_searxng"),
805            "{notice}"
806        );
807    }
808}