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