Skip to main content

trusty_common/
lib.rs

1//! Shared utility surface for trusty-* projects.
2//!
3//! Why: Port auto-detect, data-directory resolution, tracing init, NO_COLOR
4//! handling, and the OpenRouter chat-completions client appeared in both
5//! trusty-memory and trusty-search with subtle divergence. Centralising keeps
6//! them aligned and gives future trusty-* binaries a one-import surface.
7//!
8//! What: pure utility functions — no global state. Each subsystem is a free
9//! function or a small helper struct.
10//!
11//! Test: `cargo test -p trusty-common` covers port walking, data-dir creation,
12//! and the OpenRouter request shape (without hitting the network).
13//!
14//! # Test isolation: `TRUSTY_DATA_DIR_OVERRIDE`
15//!
16//! macOS's [`dirs::data_dir()`] resolves the application-support directory via
17//! `NSFileManager`, a native Cocoa API that completely ignores the `HOME` and
18//! `XDG_DATA_HOME` environment variables. This makes it impossible to redirect
19//! data-directory access in tests using ordinary env-var tricks, because the
20//! kernel query bypasses the environment entirely.
21//!
22//! To work around this, [`resolve_data_dir`] checks the
23//! [`DATA_DIR_OVERRIDE_ENV`] (`TRUSTY_DATA_DIR_OVERRIDE`) environment variable
24//! before consulting `dirs::data_dir()`. When set, the variable's value is used
25//! as the base directory verbatim, and `dirs::data_dir()` is never called.
26//!
27//! **This escape hatch is intended for testing only.** Do not set it in
28//! production deployments; rely on the OS-standard data directory instead.
29
30use std::net::SocketAddr;
31use std::path::{Path, PathBuf};
32
33pub mod chat;
34pub mod claude_config;
35pub mod project_discovery;
36
37/// Bounded in-memory ring buffer of recent tracing log lines.
38///
39/// Why: trusty-* daemons expose a `/logs/tail` endpoint so operators can read
40/// recent logs over HTTP without file I/O or a daemon restart. The buffer and
41/// its `tracing_subscriber::Layer` live here so every daemon shares one impl.
42/// What: `LogBuffer` (thread-safe capped `VecDeque<String>`) plus
43/// `LogBufferLayer` (the tracing layer that feeds it).
44/// Test: `cargo test -p trusty-common log_buffer` covers capacity eviction,
45/// tail semantics, and layer capture.
46pub mod log_buffer;
47
48/// Process RSS / CPU sampling and data-directory sizing for daemon health.
49///
50/// Why: every trusty-* daemon's `/health` endpoint reports its own resident
51/// memory, CPU usage, and on-disk footprint; the sampling logic is identical
52/// across them so it lives here once.
53/// What: `SysMetrics` (per-process RSS + CPU sampler) and `dir_size_bytes`
54/// (recursive directory byte count).
55/// Test: `cargo test -p trusty-common sys_metrics`.
56pub mod sys_metrics;
57
58/// macOS LaunchAgent generation and lifecycle management. macOS-only —
59/// the module compiles to nothing on every other platform.
60#[cfg(target_os = "macos")]
61pub mod launchd;
62
63#[cfg(feature = "axum-server")]
64pub mod server;
65
66/// Shared JSON-RPC 2.0 / MCP primitives (formerly the `trusty-mcp-core` crate).
67///
68/// Why: Centralises `Request`/`Response`/`JsonRpcError` envelopes, the
69/// `initialize` response builder, an async stdio dispatch loop, and the
70/// OpenRPC `rpc.discover` helpers so every MCP server in the workspace
71/// imports the same types.
72/// What: Gated behind the `mcp` feature; pulls in no extra dependencies
73/// beyond `serde` / `tokio`, both of which are already required.
74/// Test: `cargo test -p trusty-common --features mcp` runs the module's
75/// own unit tests (envelope round-trips, stdio loop dispatch, OpenRPC
76/// builder shape).
77#[cfg(feature = "mcp")]
78pub mod mcp;
79
80/// General-purpose JSON-RPC client + transports (formerly the library half
81/// of the `trusty-rpc` crate).
82///
83/// Why: Both `trpc` (the CLI) and any future library consumer want one
84/// place that owns the JSON-RPC envelope construction, stdio-subprocess
85/// transport, HTTP transport, and pretty-printers.
86/// What: Gated behind the `rpc` feature; requires `uuid` for request id
87/// generation. The HTTP transport reuses the workspace `reqwest`.
88/// Test: `cargo test -p trusty-common --features rpc` runs the module's
89/// own unit tests (envelope extraction, pretty-print smoke tests).
90#[cfg(feature = "rpc")]
91pub mod rpc;
92
93/// Shared text-embedding abstraction (formerly the `trusty-embedder` crate).
94///
95/// Why: trusty-memory and trusty-search both ship near-identical `Embedder`
96/// traits and `FastEmbedder` implementations; centralising the surface here
97/// keeps them aligned and lets future consumers pick up embedding for free
98/// without a separate published crate.
99/// What: Gated behind the `embedder` feature. Exposes the `Embedder` trait,
100/// `FastEmbedder` (fastembed-rs, all-MiniLM-L6-v2, 384-d) with LRU caching
101/// and ORT warmup, and (under `embedder-test-support`) the `MockEmbedder`
102/// test double.
103/// Test: `cargo test -p trusty-common --features embedder,embedder-test-support`
104/// covers the mock embedder and ONNX-backed `#[ignore]`d integration tests.
105#[cfg(feature = "embedder")]
106pub mod embedder;
107
108/// Symbol-graph engine (formerly the `trusty-symgraph` crate).
109///
110/// Why: All trusty-* tools that touch source code (open-mpm, trusty-search,
111/// trusty-analyze) want the same `EntityType` / `RawEntity` / `EdgeKind`
112/// data shapes and (for orchestrators) the same tree-sitter pipeline. Living
113/// here lets the workspace ship one tree-sitter `links =` slot instead of
114/// juggling two crates that both claim it.
115/// What: Gated behind two features. `symgraph` exposes only the contracts
116/// surface (`EntityType`, `RawEntity`, `EdgeKind`, `fact_hash_str`, tables)
117/// — no tree-sitter, no `links` conflict. `symgraph-parser` additionally
118/// pulls in tree-sitter and the full parse → registry → emit stack.
119/// `symgraph-server` enables the HTTP server frontend.
120/// Test: `cargo test -p trusty-common --features symgraph` exercises the
121/// contracts surface; `cargo test -p trusty-symgraph` covers the parser
122/// path through the thin re-export shim.
123#[cfg(feature = "symgraph")]
124pub mod symgraph;
125
126/// Memory Palace storage engine (formerly the `trusty-memory-core` crate).
127///
128/// Why: Centralises the Memory Palace data model (`Palace` / `Wing` /
129/// `Room` / `Drawer`), storage backends (usearch vector index + SQLite
130/// knowledge graph + chat-session log + payload store), retrieval handle,
131/// and the dream / decay / analytics / git-history surfaces so every
132/// trusty-* binary that talks to a palace reuses the same types. Absorbed
133/// into `trusty-common` (issue #5 phase 2d) so we ship one fewer published
134/// crate.
135/// What: Gated behind the `memory-core` feature because it pulls in heavy
136/// storage deps (`usearch`, `rusqlite`, `r2d2`, `git2`, `kuzu`). Enables
137/// the embedder surface automatically (memory-core → embedder).
138/// Test: `cargo test -p trusty-common --features memory-core` exercises
139/// the full surface.
140#[cfg(feature = "memory-core")]
141pub mod memory_core;
142
143/// Unified monitor TUI for the trusty-search and trusty-memory daemons
144/// (formerly the `trusty-monitor-tui` crate).
145///
146/// Why: operators run both daemons and want one terminal surface that shows
147/// the health of both at a glance. Living here behind the `monitor-tui`
148/// feature flag matches the workspace's "one fewer published crate" direction
149/// (issue #31 companion) and keeps the dashboard logic unit-testable.
150/// What: gated behind the `monitor-tui` feature, which pulls in `ratatui` and
151/// `crossterm`. Exposes `monitor::run` (the entry point the `trusty-monitor`
152/// binary calls) plus the pure `dashboard` / `search_client` / `memory_client`
153/// submodules.
154/// Test: `cargo test -p trusty-common --features monitor-tui` covers the
155/// rendering, layout, and HTTP-client pieces.
156#[cfg(feature = "monitor-tui")]
157pub mod monitor;
158
159pub use chat::{
160    ChatEvent, ChatProvider, LocalModelConfig, OllamaProvider, OpenRouterProvider, ToolCall,
161    ToolDef, auto_detect_local_provider,
162};
163
164use anyhow::{Context, Result, anyhow};
165use serde::{Deserialize, Serialize};
166use tokio::net::TcpListener;
167
168// ─── Port binding ─────────────────────────────────────────────────────────
169
170/// Bind to `addr`; if the port is in use, walk forward up to `max_attempts`
171/// ports and return the first listener that binds.
172///
173/// Why: Running multiple instances of a trusty-* daemon (or restarting before
174/// the kernel releases the prior socket) shouldn't produce a noisy failure —
175/// auto-incrementing gives a friendlier developer experience while still
176/// honouring the user's preferred starting port.
177/// What: returns the first successful `tokio::net::TcpListener`. Callers can
178/// inspect `local_addr()` to discover where it landed and report it however
179/// they prefer — this function does not perform any I/O on stdout/stderr.
180/// `max_attempts == 0` means "try `addr` exactly once".
181/// Test: `auto_port_walks_forward` binds a port, then calls this with the
182/// occupied port and confirms a different free port is returned.
183pub async fn bind_with_auto_port(addr: SocketAddr, max_attempts: u16) -> Result<TcpListener> {
184    use std::io::ErrorKind;
185    let mut current = addr;
186    for attempt in 0..=max_attempts {
187        match TcpListener::bind(current).await {
188            Ok(l) => return Ok(l),
189            Err(e) if e.kind() == ErrorKind::AddrInUse && attempt < max_attempts => {
190                let next_port = current.port().saturating_add(1);
191                if next_port == 0 {
192                    anyhow::bail!("ran out of ports while searching for free slot");
193                }
194                tracing::warn!("port {} in use, trying {}", current.port(), next_port);
195                current.set_port(next_port);
196            }
197            Err(e) => return Err(e.into()),
198        }
199    }
200    anyhow::bail!("could not find free port after {max_attempts} attempts")
201}
202
203// ─── Data directory ───────────────────────────────────────────────────────
204
205/// Environment variable name for the data-directory test escape hatch.
206///
207/// Why: macOS's `dirs::data_dir()` delegates to `NSFileManager`, a native Cocoa
208/// API that ignores `HOME` and `XDG_DATA_HOME`. Setting `HOME` in a test process
209/// does **not** redirect `dirs::data_dir()` on macOS, making path isolation
210/// impossible without a separate bypass. This constant names that bypass.
211///
212/// What: When `TRUSTY_DATA_DIR_OVERRIDE` is set in the environment,
213/// [`resolve_data_dir`] uses its value as the base directory and skips the
214/// `dirs::data_dir()` call entirely. The final path is
215/// `${TRUSTY_DATA_DIR_OVERRIDE}/<app_name>`, identical in structure to the
216/// normal OS-standard path.
217///
218/// **Intended for tests only.** Do not set this variable in production; it
219/// bypasses the OS-standard application-data directory.
220///
221/// Test: All `resolve_data_dir` tests in this module set this var to a
222/// temporary directory so they run identically on macOS, Linux, and Windows.
223pub const DATA_DIR_OVERRIDE_ENV: &str = "TRUSTY_DATA_DIR_OVERRIDE";
224
225/// Resolve `<data_dir>/<app_name>`, creating it if it doesn't exist.
226///
227/// Why: All trusty-* tools want a per-machine, per-app directory under the
228/// OS-standard data dir (`~/Library/Application Support/`, `~/.local/share/`,
229/// `%APPDATA%/`). If `dirs::data_dir()` is unavailable (rare — locked-down
230/// containers), falls back to `~/.<app_name>` so the tool still works.
231///
232/// The [`DATA_DIR_OVERRIDE_ENV`] (`TRUSTY_DATA_DIR_OVERRIDE`) environment
233/// variable provides a test escape hatch: when set, `dirs::data_dir()` is
234/// **never called** and the variable's value is used as the base directory
235/// instead. This is necessary because macOS's `dirs::data_dir()` calls
236/// `NSFileManager` — a native Cocoa API that resolves the application-support
237/// directory through the system rather than through the process environment —
238/// so setting `HOME` or `XDG_DATA_HOME` in a test process does not redirect
239/// it. `TRUSTY_DATA_DIR_OVERRIDE` is the only reliable cross-platform way to
240/// isolate test data paths. **It is intended for tests only; do not set it in
241/// production.**
242///
243/// What: returns the absolute path `${base}/<app_name>` (created if absent).
244/// Resolution order:
245/// 1. `$TRUSTY_DATA_DIR_OVERRIDE/<app_name>` — when the env var is set.
246/// 2. `$(dirs::data_dir())/<app_name>` — normal OS-standard path.
247/// 3. `~/.<app_name>` — fallback when `dirs::data_dir()` returns `None`.
248///
249/// Test: `resolve_data_dir_creates_directory` pins a temporary directory via
250/// `TRUSTY_DATA_DIR_OVERRIDE` and asserts that the returned path is created
251/// under it, exercising both the override path and directory-creation logic.
252pub fn resolve_data_dir(app_name: &str) -> Result<PathBuf> {
253    let base = if let Ok(override_dir) = std::env::var(DATA_DIR_OVERRIDE_ENV) {
254        PathBuf::from(override_dir)
255    } else {
256        dirs::data_dir()
257            .or_else(|| dirs::home_dir().map(|h| h.join(format!(".{app_name}"))))
258            .context("could not resolve data directory or home directory")?
259    };
260    let dir = if base.ends_with(format!(".{app_name}")) {
261        base
262    } else {
263        base.join(app_name)
264    };
265    std::fs::create_dir_all(&dir)
266        .with_context(|| format!("create data directory {}", dir.display()))?;
267    Ok(dir)
268}
269
270// ─── Daemon address file ──────────────────────────────────────────────────
271
272/// Filename used inside each app's data directory to record the daemon's
273/// bound HTTP address. Kept as a module-level constant so writers and readers
274/// can't drift.
275const DAEMON_ADDR_FILENAME: &str = "http_addr";
276
277/// Write the daemon's bound HTTP address to the app's data directory.
278///
279/// Why: Both trusty-search and trusty-memory persist their bound `host:port`
280/// to disk so MCP clients (and follow-up CLI invocations) can discover where
281/// the daemon ended up after auto-port-walking. Centralising the path layout
282/// keeps the two projects in sync and prevents a third trusty-* daemon from
283/// inventing yet another location.
284/// What: writes `addr` verbatim (no trailing newline) to
285/// `{resolve_data_dir(app_name)}/http_addr`, creating the directory if it
286/// doesn't yet exist. Atomic-overwrite semantics aren't required — the file
287/// is rewritten on every daemon start.
288/// Test: `daemon_addr_round_trips` writes then reads under a stubbed HOME and
289/// confirms equality.
290pub fn write_daemon_addr(app_name: &str, addr: &str) -> Result<()> {
291    let dir = resolve_data_dir(app_name)?;
292    let path = dir.join(DAEMON_ADDR_FILENAME);
293    std::fs::write(&path, addr).with_context(|| format!("write daemon addr to {}", path.display()))
294}
295
296/// Read the daemon's HTTP address from the app's data directory.
297///
298/// Why: CLI commands and MCP clients need to discover the running daemon's
299/// bound port. Returning `Option` lets callers distinguish "daemon never
300/// started" (file absent) from "filesystem error" (permission denied, etc.)
301/// without resorting to string matching on error messages.
302/// What: reads `{resolve_data_dir(app_name)}/http_addr`, trims surrounding
303/// whitespace, and returns `Some(addr)`. Returns `Ok(None)` iff the file
304/// does not exist; any other I/O error propagates as `Err`.
305/// Test: `daemon_addr_round_trips` and `read_daemon_addr_missing_returns_none`.
306pub fn read_daemon_addr(app_name: &str) -> Result<Option<String>> {
307    let dir = resolve_data_dir(app_name)?;
308    let path = dir.join(DAEMON_ADDR_FILENAME);
309    match std::fs::read_to_string(&path) {
310        Ok(s) => Ok(Some(s.trim().to_string())),
311        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
312        Err(e) => Err(anyhow::Error::new(e))
313            .with_context(|| format!("read daemon addr from {}", path.display())),
314    }
315}
316
317// ─── CLI initialisation ───────────────────────────────────────────────────
318
319/// Initialise the global tracing subscriber.
320///
321/// Why: Every trusty-* binary wants the same verbosity ladder and the same
322/// `RUST_LOG` override semantics. Defining it once removes the boilerplate
323/// from every `main.rs`.
324/// What: `verbose_count` maps `0 → warn`, `1 → info`, `2 → debug`, `3+ →
325/// trace`. If `RUST_LOG` is set in the environment it wins. Logs go to
326/// stderr so stdout stays clean for MCP JSON-RPC.
327/// Test: side-effecting (global subscriber) — covered by integration with
328/// `cargo run -- -v status` in downstream crates.
329pub fn init_tracing(verbose_count: u8) {
330    let default_filter = match verbose_count {
331        0 => "warn",
332        1 => "info",
333        2 => "debug",
334        _ => "trace",
335    };
336    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
337        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter));
338    // try_init so callers that pre-install a subscriber don't panic.
339    let _ = tracing_subscriber::fmt()
340        .with_env_filter(filter)
341        .with_writer(std::io::stderr)
342        .with_target(false)
343        .try_init();
344}
345
346/// Initialise the global tracing subscriber and capture events into a
347/// [`log_buffer::LogBuffer`] so the daemon can serve recent logs over HTTP.
348///
349/// Why: daemons expose `GET /logs/tail`, which needs an in-memory ring of
350/// recent log lines. Routing capture through the subscriber means every
351/// existing `tracing::info!` / `warn!` call site is mirrored automatically —
352/// no second logging API to keep in sync. The stderr `fmt` layer is retained
353/// so operators still see live logs in the terminal / launchd log file.
354/// What: builds a `tracing_subscriber::registry` with two layers — the
355/// standard stderr `fmt` layer (same verbosity ladder + `RUST_LOG` override
356/// as [`init_tracing`]) and a [`log_buffer::LogBufferLayer`] feeding the
357/// returned [`log_buffer::LogBuffer`]. Uses `try_init`, so a process that has
358/// already installed a subscriber keeps it; the returned buffer is still
359/// valid (just empty) in that case.
360/// Test: `cargo test -p trusty-common log_buffer` covers the layer; the
361/// daemon `/logs/tail` integration tests cover the wired path end-to-end.
362#[must_use]
363pub fn init_tracing_with_buffer(verbose_count: u8, capacity: usize) -> log_buffer::LogBuffer {
364    use tracing_subscriber::Layer as _;
365    use tracing_subscriber::layer::SubscriberExt;
366    use tracing_subscriber::util::SubscriberInitExt;
367
368    let default_filter = match verbose_count {
369        0 => "warn",
370        1 => "info",
371        2 => "debug",
372        _ => "trace",
373    };
374    // Stderr filter follows the same verbosity ladder + `RUST_LOG` override as
375    // `init_tracing` so terminal output stays compact at the operator's chosen
376    // level.
377    let stderr_filter = tracing_subscriber::EnvFilter::try_from_default_env()
378        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter));
379
380    // The log-buffer layer must capture activity even when the stderr filter
381    // is set to `warn` (the default for `trusty-search start` without `-v`).
382    // Operators reading `/logs/tail` expect to see info-level lifecycle events
383    // (file-watcher reindexes, startup scans). Without a separate filter the
384    // global stderr filter would suppress them before they reach the buffer.
385    // `RUST_LOG_BUFFER` lets ops widen or narrow the buffer independently of
386    // stderr; the default of `info` matches the activity feed's intent.
387    let buffer_filter = tracing_subscriber::EnvFilter::try_from_env("RUST_LOG_BUFFER")
388        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
389
390    let buffer = log_buffer::LogBuffer::new(capacity);
391    let fmt_layer = tracing_subscriber::fmt::layer()
392        .with_writer(std::io::stderr)
393        .with_target(false)
394        .with_filter(stderr_filter);
395    let buf_layer = log_buffer::LogBufferLayer::new(buffer.clone()).with_filter(buffer_filter);
396    // try_init so callers that pre-install a subscriber don't panic — the
397    // returned buffer simply stays empty in that (rare) case.
398    let _ = tracing_subscriber::registry()
399        .with(fmt_layer)
400        .with(buf_layer)
401        .try_init();
402    buffer
403}
404
405/// Disable coloured terminal output when requested or when stdout is not a TTY.
406///
407/// Why: Pipe-friendly output is mandatory for scripting (`trusty-search list
408/// | jq …`). `NO_COLOR` / `TERM=dumb` are the canonical signals; passing
409/// `--no-color` should override too.
410/// What: calls `colored::control::set_override(false)` when the caller asks
411/// for it or when the standard heuristics indicate no colour.
412/// Test: side-effecting global; trivially covered by manual `NO_COLOR=1 cargo
413/// run -- list`.
414pub fn maybe_disable_color(no_color: bool) {
415    let env_says_no =
416        std::env::var("NO_COLOR").is_ok() || std::env::var("TERM").as_deref() == Ok("dumb");
417    if no_color || env_says_no {
418        colored::control::set_override(false);
419    }
420}
421
422// ─── OpenRouter ───────────────────────────────────────────────────────────
423
424const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
425const HTTP_REFERER: &str = "https://github.com/bobmatnyc/trusty-common";
426const X_TITLE: &str = "trusty-common";
427const OPENROUTER_CONNECT_TIMEOUT_SECS: u64 = 10;
428const OPENROUTER_REQUEST_TIMEOUT_SECS: u64 = 120; // chat completions can take 60–90s
429
430/// OpenAI-compatible chat message.
431///
432/// Why: Both trusty-memory's `chat` subcommand and trusty-search's `/chat`
433/// endpoint speak the OpenRouter format. Sharing the struct keeps them in
434/// step (and lets callers compose chat histories without re-defining types).
435/// Tool-use additions (`tool_call_id`, `tool_calls`) follow the OpenAI
436/// function-calling shape: assistant messages set `tool_calls` when the model
437/// requests tool invocations; subsequent `role: "tool"` messages echo the
438/// matching `tool_call_id` with the tool's result in `content`.
439/// What: `role` is one of `"system" | "user" | "assistant" | "tool"`.
440/// `content` is the message text. `tool_call_id` is the id of the tool call
441/// this message is replying to (only set when `role == "tool"`). `tool_calls`
442/// is the raw OpenAI `tool_calls` array on an assistant message that asked
443/// to invoke tools — kept as `serde_json::Value` so we don't drop any fields
444/// the upstream may add.
445/// Test: serde round-trip in `chat_message_round_trips`.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct ChatMessage {
448    pub role: String,
449    pub content: String,
450    #[serde(skip_serializing_if = "Option::is_none", default)]
451    pub tool_call_id: Option<String>,
452    #[serde(skip_serializing_if = "Option::is_none", default)]
453    pub tool_calls: Option<Vec<serde_json::Value>>,
454}
455
456#[derive(Debug, Serialize)]
457struct ChatRequest<'a> {
458    model: &'a str,
459    messages: &'a [ChatMessage],
460    stream: bool,
461}
462
463#[derive(Debug, Deserialize)]
464struct ChatResponse {
465    choices: Vec<Choice>,
466}
467
468#[derive(Debug, Deserialize)]
469struct Choice {
470    message: ResponseMessage,
471}
472
473#[derive(Debug, Deserialize)]
474struct ResponseMessage {
475    #[serde(default)]
476    content: String,
477}
478
479/// Send a chat completion request to OpenRouter and return the assistant's
480/// message content.
481///
482/// Why: A one-shot, non-streaming chat call is the common-case helper — used
483/// by trusty-memory's `chat` CLI and trusty-search's `/chat` endpoint.
484/// What: POSTs `{model, messages, stream: false}` to OpenRouter with bearer
485/// auth, decodes the response, and returns `choices[0].message.content`.
486/// Errors propagate as anyhow with HTTP status context.
487/// Test: error paths covered by `openrouter_propagates_http_errors` (uses a
488/// blackhole base URL — no real call).
489#[deprecated(since = "0.3.1", note = "Use OpenRouterProvider::chat_stream instead")]
490pub async fn openrouter_chat(
491    api_key: &str,
492    model: &str,
493    messages: Vec<ChatMessage>,
494) -> Result<String> {
495    if api_key.is_empty() {
496        return Err(anyhow!("openrouter api key is empty"));
497    }
498    let client = reqwest::Client::builder()
499        .connect_timeout(std::time::Duration::from_secs(
500            OPENROUTER_CONNECT_TIMEOUT_SECS,
501        ))
502        .timeout(std::time::Duration::from_secs(
503            OPENROUTER_REQUEST_TIMEOUT_SECS,
504        ))
505        .build()
506        .context("build reqwest client for openrouter_chat")?;
507    let body = ChatRequest {
508        model,
509        messages: &messages,
510        stream: false,
511    };
512    let resp = client
513        .post(OPENROUTER_URL)
514        .bearer_auth(api_key)
515        .header("HTTP-Referer", HTTP_REFERER)
516        .header("X-Title", X_TITLE)
517        .json(&body)
518        .send()
519        .await
520        .context("POST openrouter chat completions")?;
521    let status = resp.status();
522    if !status.is_success() {
523        let text = resp.text().await.unwrap_or_default();
524        return Err(anyhow!("openrouter HTTP {status}: {text}"));
525    }
526    let payload: ChatResponse = resp.json().await.context("decode openrouter response")?;
527    payload
528        .choices
529        .into_iter()
530        .next()
531        .map(|c| c.message.content)
532        .ok_or_else(|| anyhow!("openrouter returned no choices"))
533}
534
535/// Stream chat-completion deltas from OpenRouter through a tokio mpsc channel.
536///
537/// Why: `chat` UIs want incremental tokens for a responsive feel; the
538/// streaming endpoint emits SSE `data:` frames with delta content.
539/// What: POSTs the request with `stream: true`, parses each SSE `data:` line
540/// as a JSON object, extracts `choices[0].delta.content`, and sends each
541/// non-empty chunk to `tx`. The function returns when the stream terminates
542/// (either by `[DONE]` sentinel or by upstream EOF).
543/// Test: integration-only (no offline mock); covered manually via the
544/// trusty-search `/chat` endpoint that re-uses this helper.
545#[deprecated(since = "0.3.1", note = "Use OpenRouterProvider::chat_stream instead")]
546pub async fn openrouter_chat_stream(
547    api_key: &str,
548    model: &str,
549    messages: Vec<ChatMessage>,
550    tx: tokio::sync::mpsc::Sender<String>,
551) -> Result<()> {
552    use futures_util::StreamExt;
553
554    if api_key.is_empty() {
555        return Err(anyhow!("openrouter api key is empty"));
556    }
557    let client = reqwest::Client::builder()
558        .connect_timeout(std::time::Duration::from_secs(
559            OPENROUTER_CONNECT_TIMEOUT_SECS,
560        ))
561        .timeout(std::time::Duration::from_secs(
562            OPENROUTER_REQUEST_TIMEOUT_SECS,
563        ))
564        .build()
565        .context("build reqwest client for openrouter_chat_stream")?;
566    let body = ChatRequest {
567        model,
568        messages: &messages,
569        stream: true,
570    };
571    let resp = client
572        .post(OPENROUTER_URL)
573        .bearer_auth(api_key)
574        .header("HTTP-Referer", HTTP_REFERER)
575        .header("X-Title", X_TITLE)
576        .json(&body)
577        .send()
578        .await
579        .context("POST openrouter chat completions (stream)")?;
580    let status = resp.status();
581    if !status.is_success() {
582        let text = resp.text().await.unwrap_or_default();
583        return Err(anyhow!("openrouter HTTP {status}: {text}"));
584    }
585
586    let mut buf = String::new();
587    let mut stream = resp.bytes_stream();
588    while let Some(chunk) = stream.next().await {
589        let bytes = chunk.context("read openrouter stream chunk")?;
590        let text = match std::str::from_utf8(&bytes) {
591            Ok(s) => s,
592            Err(_) => continue,
593        };
594        buf.push_str(text);
595
596        while let Some(idx) = buf.find('\n') {
597            let line: String = buf.drain(..=idx).collect();
598            let line = line.trim();
599            let Some(payload) = line.strip_prefix("data:").map(str::trim) else {
600                continue;
601            };
602            if payload.is_empty() || payload == "[DONE]" {
603                continue;
604            }
605            let v: serde_json::Value = match serde_json::from_str(payload) {
606                Ok(v) => v,
607                Err(_) => continue,
608            };
609            if let Some(delta) = v
610                .get("choices")
611                .and_then(|c| c.get(0))
612                .and_then(|c| c.get("delta"))
613                .and_then(|d| d.get("content"))
614                .and_then(|c| c.as_str())
615                && !delta.is_empty()
616                && tx.send(delta.to_string()).await.is_err()
617            {
618                // Receiver dropped — caller has lost interest.
619                return Ok(());
620            }
621        }
622    }
623    Ok(())
624}
625
626// ─── Misc helpers ─────────────────────────────────────────────────────────
627
628/// Check whether a path exists and is a directory.
629///
630/// Why: tiny but commonly-needed shim — clearer at call sites than
631/// `path.exists() && path.is_dir()`.
632/// What: returns `true` iff the path exists and metadata reports a directory.
633/// Test: `is_dir_recognises_directories`.
634pub fn is_dir(path: &Path) -> bool {
635    path.metadata().map(|m| m.is_dir()).unwrap_or(false)
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use std::sync::Mutex;
642
643    /// Serialises tests that mutate the `TRUSTY_DATA_DIR_OVERRIDE` env var so
644    /// they don't race when `cargo test` runs them in parallel threads.
645    static ENV_LOCK: Mutex<()> = Mutex::new(());
646
647    #[tokio::test]
648    async fn auto_port_walks_forward() {
649        // Bind to an OS-chosen port, then ask auto-port to start there.
650        let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
651        let port = occupied.local_addr().unwrap().port();
652        let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
653        let next = bind_with_auto_port(addr, 8).await.unwrap();
654        let got = next.local_addr().unwrap().port();
655        assert_ne!(got, port, "expected walk-forward to a different port");
656    }
657
658    #[tokio::test]
659    async fn auto_port_zero_attempts_still_binds_free() {
660        let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
661        let l = bind_with_auto_port(addr, 0).await.unwrap();
662        assert!(l.local_addr().unwrap().port() > 0);
663    }
664
665    #[test]
666    fn resolve_data_dir_creates_directory() {
667        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
668        // Use the override env var so we deterministically control the base
669        // directory cross-platform (macOS's dirs::data_dir ignores HOME).
670        let tmp = tempfile_like_dir();
671        // SAFETY: env mutation; tests in this module run serially via
672        // #[test] threading isolation only when MUTEX-guarded — we accept
673        // the residual risk since the override var is unique to these tests.
674        unsafe {
675            std::env::set_var(DATA_DIR_OVERRIDE_ENV, &tmp);
676        }
677        let dir = resolve_data_dir("trusty-test-xyz").unwrap();
678        assert!(
679            dir.exists(),
680            "data dir should be created at {}",
681            dir.display()
682        );
683        assert!(dir.is_dir());
684        assert!(
685            dir.starts_with(&tmp),
686            "data dir {} should live under override {}",
687            dir.display(),
688            tmp.display()
689        );
690        unsafe {
691            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
692        }
693    }
694
695    #[test]
696    fn daemon_addr_round_trips() {
697        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
698        let tmp = tempfile_like_dir();
699        // SAFETY: env mutation; see note in resolve_data_dir_creates_directory.
700        unsafe {
701            std::env::set_var(DATA_DIR_OVERRIDE_ENV, &tmp);
702        }
703        let app = format!(
704            "trusty-test-daemon-{}-{}",
705            std::process::id(),
706            std::time::SystemTime::now()
707                .duration_since(std::time::UNIX_EPOCH)
708                .map(|d| d.as_nanos())
709                .unwrap_or(0)
710        );
711        write_daemon_addr(&app, "127.0.0.1:12345").unwrap();
712        let got = read_daemon_addr(&app).unwrap();
713        unsafe {
714            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
715        }
716        assert_eq!(got.as_deref(), Some("127.0.0.1:12345"));
717    }
718
719    #[test]
720    fn read_daemon_addr_missing_returns_none() {
721        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
722        let tmp = tempfile_like_dir();
723        // SAFETY: env mutation; see note in resolve_data_dir_creates_directory.
724        unsafe {
725            std::env::set_var(DATA_DIR_OVERRIDE_ENV, &tmp);
726        }
727        let app = format!(
728            "trusty-test-daemon-missing-{}-{}",
729            std::process::id(),
730            std::time::SystemTime::now()
731                .duration_since(std::time::UNIX_EPOCH)
732                .map(|d| d.as_nanos())
733                .unwrap_or(0)
734        );
735        let got = read_daemon_addr(&app).unwrap();
736        unsafe {
737            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
738        }
739        assert!(got.is_none(), "expected None when file absent, got {got:?}");
740    }
741
742    #[test]
743    fn is_dir_recognises_directories() {
744        let tmp = tempfile_like_dir();
745        assert!(is_dir(&tmp));
746        assert!(!is_dir(&tmp.join("nope")));
747    }
748
749    #[test]
750    fn chat_message_round_trips() {
751        let m = ChatMessage {
752            role: "user".into(),
753            content: "hello".into(),
754            tool_call_id: None,
755            tool_calls: None,
756        };
757        let s = serde_json::to_string(&m).unwrap();
758        let back: ChatMessage = serde_json::from_str(&s).unwrap();
759        assert_eq!(back.role, "user");
760        assert_eq!(back.content, "hello");
761    }
762
763    #[tokio::test]
764    #[allow(deprecated)]
765    async fn openrouter_chat_rejects_empty_key() {
766        let err = openrouter_chat("", "x", vec![]).await.unwrap_err();
767        assert!(err.to_string().contains("api key"));
768    }
769
770    // Test-only helper: makes a unique scratch dir without pulling in tempfile
771    // as a dev-dep (keeps the dependency surface minimal).
772    fn tempfile_like_dir() -> PathBuf {
773        let pid = std::process::id();
774        let nanos = std::time::SystemTime::now()
775            .duration_since(std::time::UNIX_EPOCH)
776            .map(|d| d.as_nanos())
777            .unwrap_or(0);
778        let p = std::env::temp_dir().join(format!("trusty-common-test-{pid}-{nanos}"));
779        std::fs::create_dir_all(&p).unwrap();
780        p
781    }
782}