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