zeph_common/types.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Strongly-typed identifiers and shared tool types across `zeph-*` crates.
5//!
6//! This module defines `ToolName`, `ProviderName`, `SkillName`, `SessionId`, and
7//! `ToolDefinition` — types shared by multiple crates without creating cross-crate
8//! dependencies.
9//!
10//! `ToolName`, `ProviderName`, `SkillName`, and `SessionId` use `#[serde(transparent)]`
11//! for zero-cost serialization compatibility: the JSON wire format is unchanged relative
12//! to plain `String` fields.
13
14use std::borrow::Borrow;
15use std::fmt;
16use std::str::FromStr;
17use std::sync::Arc;
18
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22/// Generates an `Arc<str>`-backed newtype with the shared trait surface used by
23/// `ToolName`, `ProviderName`, and `SkillName`: `Default`, `Display`, `AsRef<str>`,
24/// `Borrow<str>`, `From<&str>`, `From<String>`, `FromStr`, and both directions of
25/// `PartialEq` against `str`/`&str`/`String`, plus `new`/`as_str` constructors.
26///
27/// `Borrow<str>` and `derive(Hash)` are kept consistent so instances can be used as
28/// `HashMap` keys and looked up by `&str` without allocating.
29macro_rules! arc_str_newtype {
30 (
31 $(#[$struct_doc:meta])*
32 struct $name:ident;
33 new_doc: $(#[$new_doc:meta])*
34 as_str_doc: $(#[$as_str_doc:meta])*
35 default_doc: $(#[$default_doc:meta])*
36 ) => {
37 $(#[$struct_doc])*
38 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
39 #[serde(transparent)]
40 pub struct $name(Arc<str>);
41
42 impl $name {
43 $(#[$new_doc])*
44 #[must_use]
45 pub fn new(s: impl Into<Arc<str>>) -> Self {
46 Self(s.into())
47 }
48
49 $(#[$as_str_doc])*
50 #[must_use]
51 pub fn as_str(&self) -> &str {
52 &self.0
53 }
54 }
55
56 impl Default for $name {
57 $(#[$default_doc])*
58 fn default() -> Self {
59 Self(Arc::from(""))
60 }
61 }
62
63 impl fmt::Display for $name {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 f.write_str(&self.0)
66 }
67 }
68
69 impl AsRef<str> for $name {
70 fn as_ref(&self) -> &str {
71 &self.0
72 }
73 }
74
75 impl Borrow<str> for $name {
76 fn borrow(&self) -> &str {
77 &self.0
78 }
79 }
80
81 impl From<&str> for $name {
82 fn from(s: &str) -> Self {
83 Self(Arc::from(s))
84 }
85 }
86
87 impl From<String> for $name {
88 fn from(s: String) -> Self {
89 Self(Arc::from(s.as_str()))
90 }
91 }
92
93 impl FromStr for $name {
94 type Err = std::convert::Infallible;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 Ok(Self::from(s))
98 }
99 }
100
101 impl PartialEq<str> for $name {
102 fn eq(&self, other: &str) -> bool {
103 self.0.as_ref() == other
104 }
105 }
106
107 impl PartialEq<&str> for $name {
108 fn eq(&self, other: &&str) -> bool {
109 self.0.as_ref() == *other
110 }
111 }
112
113 impl PartialEq<String> for $name {
114 fn eq(&self, other: &String) -> bool {
115 self.0.as_ref() == other.as_str()
116 }
117 }
118
119 impl PartialEq<$name> for str {
120 fn eq(&self, other: &$name) -> bool {
121 self == other.0.as_ref()
122 }
123 }
124
125 impl PartialEq<$name> for String {
126 fn eq(&self, other: &$name) -> bool {
127 self.as_str() == other.0.as_ref()
128 }
129 }
130 };
131}
132
133arc_str_newtype!(
134 /// Strongly-typed tool name label.
135 ///
136 /// `ToolName` identifies a tool by its canonical name (e.g., `"shell"`, `"web_scrape"`).
137 /// It is produced by the LLM in JSON tool-use responses and matched against the registered
138 /// tool registry at dispatch time.
139 ///
140 /// # Label semantics (not a validated reference)
141 ///
142 /// `ToolName` is an unvalidated label from untrusted input (LLM JSON). It does **not**
143 /// guarantee that a tool with this name is registered. Validation happens downstream at
144 /// tool dispatch, not at construction.
145 ///
146 /// # Inner type: `Arc<str>`
147 ///
148 /// The inner type is `Arc<str>`, not `String`. Tool names are cloned into multiple contexts
149 /// (event channels, tracing spans, tool output structs) during a single tool execution.
150 /// `Arc<str>` makes all clones O(1) vs O(n) for `String`. Use `.clone()` to duplicate
151 /// a `ToolName` — it is cheap.
152 ///
153 /// # No `Deref<Target=str>`
154 ///
155 /// `ToolName` does **not** implement `Deref<Target=str>`. This prevents the `.to_owned()`
156 /// footgun where muscle memory returns `String` instead of `ToolName`. Use `.as_str()` for
157 /// explicit string conversion and `.clone()` to duplicate the `ToolName`.
158 ///
159 /// # Examples
160 ///
161 /// ```
162 /// use zeph_common::ToolName;
163 ///
164 /// let name = ToolName::new("shell");
165 /// assert_eq!(name.as_str(), "shell");
166 /// assert_eq!(name, "shell");
167 ///
168 /// // Clone is O(1) — Arc reference count increment only.
169 /// let name2 = name.clone();
170 /// assert_eq!(name, name2);
171 /// ```
172 struct ToolName;
173 new_doc:
174 /// Construct a `ToolName` from any value convertible to `Arc<str>`.
175 ///
176 /// This is the primary constructor. The name is accepted without validation — it is a
177 /// label from the LLM wire or tool registry, not a proof of registration.
178 ///
179 /// # Examples
180 ///
181 /// ```
182 /// use zeph_common::ToolName;
183 ///
184 /// let name = ToolName::new("shell");
185 /// assert_eq!(name.as_str(), "shell");
186 /// ```
187 as_str_doc:
188 /// Return the inner string slice.
189 ///
190 /// Prefer this over `Deref` (which is intentionally not implemented) when an `&str`
191 /// reference is needed.
192 ///
193 /// # Examples
194 ///
195 /// ```
196 /// use zeph_common::ToolName;
197 ///
198 /// let name = ToolName::new("web_scrape");
199 /// assert_eq!(name.as_str(), "web_scrape");
200 /// ```
201 default_doc:
202 /// Returns an empty `ToolName`.
203 ///
204 /// This implementation exists solely for `#[serde(default)]` on optional fields.
205 /// Do not construct a `ToolName` with an empty string in application code.
206);
207
208// ── ProviderName ─────────────────────────────────────────────────────────────
209
210arc_str_newtype!(
211 /// Strongly-typed LLM provider name.
212 ///
213 /// `ProviderName` identifies a configured provider by its name field (e.g., `"fast"`,
214 /// `"quality"`, `"ollama-local"`). Names come from `[[llm.providers]] name = "…"` in the
215 /// TOML config; subsystems reference providers by this name via `*_provider` fields.
216 ///
217 /// # Inner type: `Arc<str>`
218 ///
219 /// The inner type is `Arc<str>`. Provider names are cloned widely across subsystem config
220 /// structs, metric labels, and log spans. `Arc<str>` makes all clones O(1).
221 ///
222 /// # No `Deref<Target=str>`
223 ///
224 /// `ProviderName` does **not** implement `Deref<Target=str>`. Use `.as_str()` for explicit
225 /// string conversion and `.clone()` to duplicate.
226 ///
227 /// # Examples
228 ///
229 /// ```
230 /// use zeph_common::ProviderName;
231 ///
232 /// let name = ProviderName::new("fast");
233 /// assert_eq!(name.as_str(), "fast");
234 /// assert_eq!(name, "fast");
235 ///
236 /// // Clone is O(1) — Arc reference count increment only.
237 /// let name2 = name.clone();
238 /// assert_eq!(name, name2);
239 /// ```
240 struct ProviderName;
241 new_doc:
242 /// Construct a `ProviderName` from any value convertible to `Arc<str>`.
243 ///
244 /// # Examples
245 ///
246 /// ```
247 /// use zeph_common::ProviderName;
248 ///
249 /// let name = ProviderName::new("quality");
250 /// assert_eq!(name.as_str(), "quality");
251 /// ```
252 as_str_doc:
253 /// Return the inner string slice.
254 ///
255 /// # Examples
256 ///
257 /// ```
258 /// use zeph_common::ProviderName;
259 ///
260 /// let name = ProviderName::new("ollama-local");
261 /// assert_eq!(name.as_str(), "ollama-local");
262 /// ```
263 default_doc:
264 /// Returns an empty `ProviderName`.
265 ///
266 /// Exists solely for `#[serde(default)]` on optional fields. Do not use in
267 /// application code — an empty name will fail provider lookup.
268);
269
270impl ProviderName {
271 /// Return `true` when this is the empty sentinel (use the primary provider).
272 ///
273 /// # Examples
274 ///
275 /// ```
276 /// use zeph_common::ProviderName;
277 ///
278 /// assert!(ProviderName::default().is_empty());
279 /// assert!(!ProviderName::new("fast").is_empty());
280 /// ```
281 #[must_use]
282 pub fn is_empty(&self) -> bool {
283 self.0.is_empty()
284 }
285
286 /// Return `Some(&str)` when non-empty, `None` for the empty sentinel.
287 ///
288 /// # Examples
289 ///
290 /// ```
291 /// use zeph_common::ProviderName;
292 ///
293 /// assert_eq!(ProviderName::default().as_non_empty(), None);
294 /// assert_eq!(ProviderName::new("fast").as_non_empty(), Some("fast"));
295 /// ```
296 #[must_use]
297 pub fn as_non_empty(&self) -> Option<&str> {
298 if self.0.is_empty() {
299 None
300 } else {
301 Some(&self.0)
302 }
303 }
304}
305
306// ── SkillName ────────────────────────────────────────────────────────────────
307
308arc_str_newtype!(
309 /// Strongly-typed skill name identifier.
310 ///
311 /// `SkillName` identifies a skill by its canonical name (e.g., `"rust-agents"`,
312 /// `"readme-generator"`). Names come from `SKILL.md` frontmatter `name:` fields and
313 /// are used at match time, invocation routing, and telemetry.
314 ///
315 /// # Inner type: `Arc<str>`
316 ///
317 /// The inner type is `Arc<str>`. Skill names are referenced from multiple subsystems
318 /// (registry, matcher, invoker, TUI) during a single agent turn. `Arc<str>` makes all
319 /// clones O(1).
320 ///
321 /// # No `Deref<Target=str>`
322 ///
323 /// `SkillName` does **not** implement `Deref<Target=str>`. Use `.as_str()` for explicit
324 /// string conversion and `.clone()` to duplicate.
325 ///
326 /// # Examples
327 ///
328 /// ```
329 /// use zeph_common::SkillName;
330 ///
331 /// let name = SkillName::new("rust-agents");
332 /// assert_eq!(name.as_str(), "rust-agents");
333 /// assert_eq!(name, "rust-agents");
334 ///
335 /// // Clone is O(1) — Arc reference count increment only.
336 /// let name2 = name.clone();
337 /// assert_eq!(name, name2);
338 /// ```
339 struct SkillName;
340 new_doc:
341 /// Construct a `SkillName` from any value convertible to `Arc<str>`.
342 ///
343 /// # Examples
344 ///
345 /// ```
346 /// use zeph_common::SkillName;
347 ///
348 /// let name = SkillName::new("readme-generator");
349 /// assert_eq!(name.as_str(), "readme-generator");
350 /// ```
351 as_str_doc:
352 /// Return the inner string slice.
353 ///
354 /// # Examples
355 ///
356 /// ```
357 /// use zeph_common::SkillName;
358 ///
359 /// let name = SkillName::new("rust-agents");
360 /// assert_eq!(name.as_str(), "rust-agents");
361 /// ```
362 default_doc:
363 /// Returns an empty `SkillName`.
364 ///
365 /// Exists solely for `#[serde(default)]` on optional fields. Do not use in
366 /// application code — an empty name will fail skill lookup.
367);
368
369// ── SessionId ────────────────────────────────────────────────────────────────
370
371/// Identifies a single agent session (one binary invocation or one ACP connection).
372///
373/// Uses `String` internally to support both UUID-based IDs (production) and
374/// arbitrary string IDs (tests, experiments). UUID validation is enforced only at
375/// [`SessionId::generate`] time; [`SessionId::new`] accepts any non-empty string for
376/// flexibility in test fixtures.
377///
378/// Session IDs are joined onto a filesystem path by `zeph_session::session_dir`, so a
379/// string containing `/`, `\`, `..`, or a NUL byte could in principle escape the
380/// intended session directory. Every call site in this codebase constructs `SessionId`
381/// from either [`SessionId::generate`]'s own UUID output or a value already gated by a
382/// registry/`SessionStore` lookup, so this is not exploitable today — but callers that
383/// accept a session id from an untrusted boundary (e.g. an HTTP path parameter) should
384/// use [`SessionId::try_new`] instead, which rejects those characters up front.
385///
386/// # Serialization
387///
388/// `SessionId` uses `#[serde(transparent)]` — it serializes as a plain JSON string
389/// identical to the raw `String` fields it replaces. No wire format change, no DB
390/// schema migration required.
391///
392/// # ACP Note
393///
394/// `acp::SessionId` from the external `agent_client_protocol` crate is distinct.
395/// This type is for **our own** session tracking only.
396///
397/// # Examples
398///
399/// ```
400/// use zeph_common::SessionId;
401///
402/// // Production: generate a fresh UUID session
403/// let id = SessionId::generate();
404/// assert!(!id.as_str().is_empty());
405///
406/// // Tests: use a readable fixture string
407/// let test_id = SessionId::new("test-session");
408/// assert_eq!(test_id.as_str(), "test-session");
409/// ```
410#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
411#[serde(transparent)]
412pub struct SessionId(String);
413
414/// Rejection reasons for [`SessionId::try_new`].
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
416pub enum SessionIdError {
417 /// The candidate string was empty.
418 #[error("session id must not be empty")]
419 Empty,
420 /// The candidate string contains a path separator (`/` or `\`), a `..` traversal
421 /// segment, or a NUL byte — any of which could escape the intended directory when
422 /// joined onto a filesystem path by `zeph_session::session_dir`.
423 #[error("session id must not contain path separators, '..', or NUL bytes")]
424 UnsafeCharacters,
425}
426
427impl SessionId {
428 /// Create a `SessionId` from any non-empty string.
429 ///
430 /// Accepts UUID strings (production), readable names (tests), or any other
431 /// non-empty value. In debug builds, an empty string triggers a `debug_assert!`
432 /// to catch accidental construction early.
433 ///
434 /// This constructor is for **trusted** call sites only — internal generation,
435 /// test fixtures, and values already validated by a prior registry/store lookup.
436 /// For a string coming from an untrusted boundary, use [`SessionId::try_new`].
437 ///
438 /// # Panics
439 ///
440 /// Panics in **debug builds only** if `s` is empty.
441 ///
442 /// # Examples
443 ///
444 /// ```
445 /// use zeph_common::SessionId;
446 ///
447 /// let id = SessionId::new("test-session");
448 /// assert_eq!(id.as_str(), "test-session");
449 /// ```
450 pub fn new(s: impl Into<String>) -> Self {
451 let s = s.into();
452 debug_assert!(!s.is_empty(), "SessionId must not be empty");
453 Self(s)
454 }
455
456 /// Create a `SessionId` from a string, rejecting values unsafe for filesystem use.
457 ///
458 /// Rejects an empty string, and any string containing `/`, `\`, a `..` segment, or
459 /// a NUL byte — the characters that could let a `SessionId` escape its intended
460 /// directory once joined by `zeph_session::session_dir`. Use this constructor
461 /// whenever the candidate string originates from an untrusted boundary (e.g. an
462 /// HTTP path parameter or another externally-supplied identifier).
463 ///
464 /// # Errors
465 ///
466 /// Returns [`SessionIdError::Empty`] if `s` is empty, or
467 /// [`SessionIdError::UnsafeCharacters`] if `s` contains `/`, `\`, `..`, or a NUL byte.
468 ///
469 /// # Examples
470 ///
471 /// ```
472 /// use zeph_common::{SessionId, SessionIdError};
473 ///
474 /// let id = SessionId::try_new("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap();
475 /// assert_eq!(id.as_str(), "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
476 ///
477 /// assert_eq!(SessionId::try_new("../etc/passwd"), Err(SessionIdError::UnsafeCharacters));
478 /// assert_eq!(SessionId::try_new(""), Err(SessionIdError::Empty));
479 /// ```
480 pub fn try_new(s: impl Into<String>) -> Result<Self, SessionIdError> {
481 let s = s.into();
482 if s.is_empty() {
483 return Err(SessionIdError::Empty);
484 }
485 if s.contains('/') || s.contains('\\') || s.contains("..") || s.contains('\0') {
486 return Err(SessionIdError::UnsafeCharacters);
487 }
488 Ok(Self(s))
489 }
490
491 /// Generate a new session ID backed by a random UUID v4.
492 ///
493 /// # Examples
494 ///
495 /// ```
496 /// use zeph_common::SessionId;
497 ///
498 /// let id = SessionId::generate();
499 /// assert!(!id.as_str().is_empty());
500 /// // UUIDs are 36 chars: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
501 /// assert_eq!(id.as_str().len(), 36);
502 /// ```
503 #[must_use]
504 pub fn generate() -> Self {
505 Self(uuid::Uuid::new_v4().to_string())
506 }
507
508 /// Return the inner string slice.
509 ///
510 /// # Examples
511 ///
512 /// ```
513 /// use zeph_common::SessionId;
514 ///
515 /// let id = SessionId::new("s1");
516 /// assert_eq!(id.as_str(), "s1");
517 /// ```
518 #[must_use]
519 pub fn as_str(&self) -> &str {
520 &self.0
521 }
522}
523
524impl Default for SessionId {
525 /// Generate a new UUID-backed session ID.
526 fn default() -> Self {
527 Self::generate()
528 }
529}
530
531impl fmt::Display for SessionId {
532 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
533 f.write_str(&self.0)
534 }
535}
536
537impl AsRef<str> for SessionId {
538 fn as_ref(&self) -> &str {
539 &self.0
540 }
541}
542
543impl std::ops::Deref for SessionId {
544 type Target = str;
545
546 fn deref(&self) -> &str {
547 &self.0
548 }
549}
550
551impl From<String> for SessionId {
552 fn from(s: String) -> Self {
553 Self::new(s)
554 }
555}
556
557impl From<&str> for SessionId {
558 fn from(s: &str) -> Self {
559 Self::new(s)
560 }
561}
562
563impl From<uuid::Uuid> for SessionId {
564 fn from(u: uuid::Uuid) -> Self {
565 Self(u.to_string())
566 }
567}
568
569impl FromStr for SessionId {
570 type Err = std::convert::Infallible;
571
572 fn from_str(s: &str) -> Result<Self, Self::Err> {
573 Ok(Self::new(s))
574 }
575}
576
577impl PartialEq<str> for SessionId {
578 fn eq(&self, other: &str) -> bool {
579 self.0 == other
580 }
581}
582
583impl PartialEq<&str> for SessionId {
584 fn eq(&self, other: &&str) -> bool {
585 self.0 == *other
586 }
587}
588
589impl PartialEq<String> for SessionId {
590 fn eq(&self, other: &String) -> bool {
591 self.0 == *other
592 }
593}
594
595impl PartialEq<SessionId> for str {
596 fn eq(&self, other: &SessionId) -> bool {
597 self == other.0
598 }
599}
600
601impl PartialEq<SessionId> for String {
602 fn eq(&self, other: &SessionId) -> bool {
603 *self == other.0
604 }
605}
606
607// ── ToolDefinition ───────────────────────────────────────────────────────────
608
609/// Minimal tool definition passed to LLM providers.
610///
611/// Decoupled from `zeph-tools::ToolDef` to avoid cross-crate dependencies.
612/// Providers translate this into their native tool/function format before sending to the API.
613///
614/// # Examples
615///
616/// ```
617/// use zeph_common::types::ToolDefinition;
618/// use zeph_common::ToolName;
619///
620/// let tool = ToolDefinition {
621/// name: ToolName::new("get_weather"),
622/// description: "Return current weather for a city.".into(),
623/// parameters: serde_json::json!({
624/// "type": "object",
625/// "properties": {
626/// "city": { "type": "string" }
627/// },
628/// "required": ["city"]
629/// }),
630/// output_schema: None,
631/// };
632/// assert_eq!(tool.name, "get_weather");
633/// ```
634#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
635pub struct ToolDefinition {
636 /// Tool name — must match the name used in the response `ToolUseRequest`.
637 pub name: ToolName,
638 /// Human-readable description guiding the model on when to call this tool.
639 pub description: String,
640 /// JSON Schema object describing parameters.
641 pub parameters: serde_json::Value,
642 /// Raw output schema advertised by the MCP server, if present.
643 ///
644 /// When `mcp.forward_output_schema = true`, LLM provider assemblers append a compact JSON
645 /// hint to the tool description rather than adding a new top-level field (unsupported by
646 /// the Anthropic and `OpenAI` APIs).
647 ///
648 /// DO NOT convert to `schemars::Schema` — lossy; see #2931 critique P0-1.
649 #[serde(default, skip_serializing_if = "Option::is_none")]
650 pub output_schema: Option<serde_json::Value>,
651}
652
653/// Reason why the agent turn ended early.
654///
655/// Emitted by the agent loop when a non-default terminal condition is detected.
656/// Consumers (e.g. the ACP layer) map this to the protocol-level `StopReason`.
657///
658/// # Examples
659///
660/// ```
661/// use zeph_common::StopHint;
662///
663/// let hint = StopHint::MaxTokens;
664/// assert!(matches!(hint, StopHint::MaxTokens));
665/// ```
666#[non_exhaustive]
667#[derive(Debug, Clone, Copy, PartialEq, Eq)]
668pub enum StopHint {
669 /// The LLM response was cut off by the token limit.
670 MaxTokens,
671 /// The turn loop exhausted `max_turns` without a final text response.
672 MaxTurnRequests,
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 #[test]
680 fn tool_name_construction_and_equality() {
681 let name = ToolName::new("shell");
682 assert_eq!(name.as_str(), "shell");
683 assert_eq!(name, "shell");
684 assert_eq!(name, "shell".to_owned());
685 // Reverse direction: PartialEq<ToolName> for str/String
686 assert_eq!(*"shell", name);
687 assert_eq!("shell".to_owned(), name);
688 }
689
690 #[test]
691 fn tool_name_default_is_empty() {
692 let name = ToolName::default();
693 assert_eq!(name.as_str(), "");
694 }
695
696 #[test]
697 fn tool_name_clone_is_cheap() {
698 let name = ToolName::new("web_scrape");
699 let name2 = name.clone();
700 assert_eq!(name, name2);
701 // Both Arc<str> point to same allocation
702 assert!(Arc::ptr_eq(&name.0, &name2.0));
703 }
704
705 #[test]
706 fn tool_name_from_impls() {
707 let from_str: ToolName = ToolName::from("bash");
708 let from_string: ToolName = ToolName::from("bash".to_owned());
709 let parsed: ToolName = "bash".parse().unwrap();
710 assert_eq!(from_str, from_string);
711 assert_eq!(from_str, parsed);
712 }
713
714 #[test]
715 fn tool_name_as_hashmap_key() {
716 use std::collections::HashMap;
717 let mut map: HashMap<ToolName, u32> = HashMap::new();
718 map.insert(ToolName::new("shell"), 1);
719 // Borrow<str> enables lookup by &str
720 assert_eq!(map.get("shell"), Some(&1));
721 }
722
723 #[test]
724 fn tool_name_display() {
725 let name = ToolName::new("my_tool");
726 assert_eq!(format!("{name}"), "my_tool");
727 }
728
729 #[test]
730 fn tool_name_serde_transparent() {
731 let name = ToolName::new("shell");
732 let json = serde_json::to_string(&name).unwrap();
733 assert_eq!(json, r#""shell""#);
734 let back: ToolName = serde_json::from_str(&json).unwrap();
735 assert_eq!(back, name);
736 }
737
738 #[test]
739 fn session_id_new_roundtrip() {
740 let id = SessionId::new("test-session");
741 assert_eq!(id.as_str(), "test-session");
742 assert_eq!(id.to_string(), "test-session");
743 }
744
745 #[test]
746 fn session_id_generate_is_uuid() {
747 let id = SessionId::generate();
748 assert_eq!(id.as_str().len(), 36);
749 assert!(uuid::Uuid::parse_str(id.as_str()).is_ok());
750 }
751
752 #[test]
753 fn session_id_default_is_generated() {
754 let id = SessionId::default();
755 assert!(!id.as_str().is_empty());
756 assert_eq!(id.as_str().len(), 36);
757 }
758
759 #[test]
760 fn session_id_from_uuid() {
761 let u = uuid::Uuid::new_v4();
762 let id = SessionId::from(u);
763 assert_eq!(id.as_str(), u.to_string());
764 }
765
766 #[test]
767 fn session_id_deref_slicing() {
768 let id = SessionId::new("abcdefgh");
769 // Deref<Target=str> enables string slicing
770 assert_eq!(&id[..4], "abcd");
771 }
772
773 #[test]
774 fn session_id_serde_transparent() {
775 let id = SessionId::new("sess-abc");
776 let json = serde_json::to_string(&id).unwrap();
777 assert_eq!(json, r#""sess-abc""#);
778 let back: SessionId = serde_json::from_str(&json).unwrap();
779 assert_eq!(back, id);
780 }
781
782 #[test]
783 fn session_id_from_str_parses() {
784 let id: SessionId = "my-session".parse().unwrap();
785 assert_eq!(id.as_str(), "my-session");
786 }
787
788 #[test]
789 fn session_id_try_new_accepts_valid_uuid() {
790 let id = SessionId::try_new(uuid::Uuid::new_v4().to_string()).unwrap();
791 assert_eq!(id.as_str().len(), 36);
792 }
793
794 #[test]
795 fn session_id_try_new_accepts_plain_string() {
796 let id = SessionId::try_new("sess-abc123").unwrap();
797 assert_eq!(id.as_str(), "sess-abc123");
798 }
799
800 #[test]
801 fn session_id_try_new_rejects_empty() {
802 assert_eq!(SessionId::try_new(""), Err(SessionIdError::Empty));
803 }
804
805 #[test]
806 fn session_id_try_new_rejects_path_traversal() {
807 assert_eq!(
808 SessionId::try_new("../../etc/passwd"),
809 Err(SessionIdError::UnsafeCharacters)
810 );
811 assert_eq!(
812 SessionId::try_new("foo/../bar"),
813 Err(SessionIdError::UnsafeCharacters)
814 );
815 }
816
817 #[test]
818 fn session_id_try_new_rejects_forward_slash() {
819 assert_eq!(
820 SessionId::try_new("foo/bar"),
821 Err(SessionIdError::UnsafeCharacters)
822 );
823 }
824
825 #[test]
826 fn session_id_try_new_rejects_backslash() {
827 assert_eq!(
828 SessionId::try_new("foo\\bar"),
829 Err(SessionIdError::UnsafeCharacters)
830 );
831 }
832
833 #[test]
834 fn session_id_try_new_rejects_nul_byte() {
835 assert_eq!(
836 SessionId::try_new("foo\0bar"),
837 Err(SessionIdError::UnsafeCharacters)
838 );
839 }
840
841 #[test]
842 fn provider_name_construction_and_equality() {
843 let name = ProviderName::new("fast");
844 assert_eq!(name.as_str(), "fast");
845 assert_eq!(name, "fast");
846 assert_eq!(name, "fast".to_owned());
847 // Reverse direction: PartialEq<ProviderName> for str/String
848 assert_eq!(*"fast", name);
849 assert_eq!("fast".to_owned(), name);
850 }
851
852 #[test]
853 fn provider_name_clone_is_cheap() {
854 let name = ProviderName::new("quality");
855 let name2 = name.clone();
856 assert_eq!(name, name2);
857 assert!(Arc::ptr_eq(&name.0, &name2.0));
858 }
859
860 #[test]
861 fn provider_name_from_impls() {
862 let from_str: ProviderName = ProviderName::from("fast");
863 let from_string: ProviderName = ProviderName::from("fast".to_owned());
864 let parsed: ProviderName = "fast".parse().unwrap();
865 assert_eq!(from_str, from_string);
866 assert_eq!(from_str, parsed);
867 }
868
869 #[test]
870 fn provider_name_as_hashmap_key() {
871 use std::collections::HashMap;
872 let mut map: HashMap<ProviderName, u32> = HashMap::new();
873 map.insert(ProviderName::new("fast"), 1);
874 assert_eq!(map.get("fast"), Some(&1));
875 }
876
877 #[test]
878 fn provider_name_display() {
879 let name = ProviderName::new("ollama-local");
880 assert_eq!(format!("{name}"), "ollama-local");
881 }
882
883 #[test]
884 fn provider_name_serde_transparent() {
885 let name = ProviderName::new("quality");
886 let json = serde_json::to_string(&name).unwrap();
887 assert_eq!(json, r#""quality""#);
888 let back: ProviderName = serde_json::from_str(&json).unwrap();
889 assert_eq!(back, name);
890 }
891
892 #[test]
893 fn skill_name_construction_and_equality() {
894 let name = SkillName::new("rust-agents");
895 assert_eq!(name.as_str(), "rust-agents");
896 assert_eq!(name, "rust-agents");
897 assert_eq!(name, "rust-agents".to_owned());
898 // Reverse direction: PartialEq<SkillName> for str/String
899 assert_eq!(*"rust-agents", name);
900 assert_eq!("rust-agents".to_owned(), name);
901 }
902
903 #[test]
904 fn skill_name_default_is_empty() {
905 let name = SkillName::default();
906 assert_eq!(name.as_str(), "");
907 }
908
909 #[test]
910 fn skill_name_clone_is_cheap() {
911 let name = SkillName::new("readme-generator");
912 let name2 = name.clone();
913 assert_eq!(name, name2);
914 assert!(Arc::ptr_eq(&name.0, &name2.0));
915 }
916
917 #[test]
918 fn skill_name_from_impls() {
919 let from_str: SkillName = SkillName::from("rust-agents");
920 let from_string: SkillName = SkillName::from("rust-agents".to_owned());
921 let parsed: SkillName = "rust-agents".parse().unwrap();
922 assert_eq!(from_str, from_string);
923 assert_eq!(from_str, parsed);
924 }
925
926 #[test]
927 fn skill_name_as_hashmap_key() {
928 use std::collections::HashMap;
929 let mut map: HashMap<SkillName, u32> = HashMap::new();
930 map.insert(SkillName::new("rust-agents"), 1);
931 assert_eq!(map.get("rust-agents"), Some(&1));
932 }
933
934 #[test]
935 fn skill_name_display() {
936 let name = SkillName::new("readme-generator");
937 assert_eq!(format!("{name}"), "readme-generator");
938 }
939
940 #[test]
941 fn skill_name_serde_transparent() {
942 let name = SkillName::new("rust-agents");
943 let json = serde_json::to_string(&name).unwrap();
944 assert_eq!(json, r#""rust-agents""#);
945 let back: SkillName = serde_json::from_str(&json).unwrap();
946 assert_eq!(back, name);
947 }
948}