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