Skip to main content

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}