Skip to main content

oino_extension_api/
types.rs

1//! Shared vocabulary of `types.wit`: ids, modes, call contexts, errors.
2
3use std::fmt;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8/// Extension id (`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`).
9#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(try_from = "String", into = "String")]
11pub struct ExtensionId(String);
12
13/// The id does not match the manifest id grammar.
14#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
15#[error("invalid extension id `{0}`: expected lowercase dot-separated segments of [a-z0-9-]")]
16pub struct InvalidExtensionId(pub String);
17
18impl ExtensionId {
19    /// Validates and wraps `id`.
20    ///
21    /// # Errors
22    ///
23    /// `id` violates the grammar.
24    pub fn new(id: impl Into<String>) -> Result<Self, InvalidExtensionId> {
25        let id = id.into();
26        if is_valid_id(&id) {
27            Ok(Self(id))
28        } else {
29            Err(InvalidExtensionId(id))
30        }
31    }
32
33    /// The id text.
34    #[must_use]
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40fn is_valid_id(id: &str) -> bool {
41    !id.is_empty()
42        && id.split('.').all(|segment| {
43            let mut chars = segment.chars();
44            chars
45                .next()
46                .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
47                && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
48        })
49}
50
51impl TryFrom<String> for ExtensionId {
52    type Error = InvalidExtensionId;
53    fn try_from(value: String) -> Result<Self, Self::Error> {
54        Self::new(value)
55    }
56}
57
58impl From<ExtensionId> for String {
59    fn from(value: ExtensionId) -> Self {
60        value.0
61    }
62}
63
64impl fmt::Display for ExtensionId {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(&self.0)
67    }
68}
69
70impl AsRef<str> for ExtensionId {
71    fn as_ref(&self) -> &str {
72        &self.0
73    }
74}
75
76/// `log-level`.
77#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "lowercase")]
79pub enum LogLevel {
80    /// Trace.
81    Trace,
82    /// Debug.
83    Debug,
84    /// Info.
85    Info,
86    /// Warning.
87    Warn,
88    /// Error.
89    Error,
90}
91
92/// `mode`: how the hosting process runs.
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
94#[serde(rename_all = "lowercase")]
95pub enum Mode {
96    /// Interactive terminal.
97    Tui,
98    /// RPC.
99    Rpc,
100    /// JSON event stream.
101    Json,
102    /// Print mode.
103    #[default]
104    Print,
105    /// ACP adapter.
106    Acp,
107    /// Desktop app.
108    Desktop,
109    /// Mobile app.
110    Mobile,
111}
112
113/// `call-kind`: what a guest call serves.
114#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum CallKind {
117    /// `init`.
118    Init,
119    /// Event handler.
120    #[default]
121    Event,
122    /// Tool execution.
123    Tool,
124    /// Slash command.
125    Command,
126    /// Shortcut.
127    Shortcut,
128    /// Service request.
129    Service,
130    /// Arbiter nudge.
131    Nudge,
132    /// Renderer.
133    Render,
134    /// Virtual model route.
135    Route,
136    /// Provider stream.
137    Provider,
138}
139
140impl CallKind {
141    /// Command-context-only operations are allowed.
142    #[must_use]
143    pub fn is_command_context(self) -> bool {
144        matches!(self, Self::Command | Self::Shortcut)
145    }
146}
147
148/// `call-context`: carried by every guest call; host imports made while serving the call act
149/// on its session.
150#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct CallContext {
153    /// Session the call belongs to.
154    pub session_id: String,
155    /// Run mode.
156    pub mode: Mode,
157    /// Dialog-capable UI attached.
158    pub has_ui: bool,
159    /// Session cwd.
160    pub cwd: PathBuf,
161    /// Nested (child) session.
162    pub is_child: bool,
163    /// Child role for nested sessions.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub child_role: Option<String>,
166    /// Call kind.
167    pub kind: CallKind,
168    /// Set while serving a tool call (enables `control.execute-tool`).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub tool_call_id: Option<String>,
171}
172
173impl CallContext {
174    /// The same context for another call kind (clears the tool call id unless `Tool`).
175    #[must_use]
176    pub fn with_kind(&self, kind: CallKind) -> Self {
177        Self {
178            kind,
179            tool_call_id: if kind == CallKind::Tool {
180                self.tool_call_id.clone()
181            } else {
182                None
183            },
184            ..self.clone()
185        }
186    }
187}
188
189/// `host-error`: failure of a host import.
190#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
191#[non_exhaustive]
192pub enum HostError {
193    /// Permission missing (names the permission).
194    #[error("permission denied: {0}")]
195    Denied(String),
196    /// Session replaced since this call started.
197    #[error(
198        "This extension ctx is stale after session replacement or reload. Do not use a captured session or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload()."
199    )]
200    StaleContext,
201    /// Session-replacing operations need a command or shortcut context.
202    #[error("This operation is only available in command and shortcut contexts")]
203    CommandContextRequired,
204    /// Invalid argument.
205    #[error("{0}")]
206    InvalidArgument(String),
207    /// Not found.
208    #[error("not found: {0}")]
209    NotFound(String),
210    /// Timed out.
211    #[error("timed out")]
212    Timeout,
213    /// Cancelled.
214    #[error("cancelled")]
215    Cancelled,
216    /// I/O failure.
217    #[error("{0}")]
218    Io(String),
219    /// Anything else.
220    #[error("{0}")]
221    Failed(String),
222}
223
224impl HostError {
225    /// The host does not implement `capability` (yet).
226    #[must_use]
227    pub fn unsupported(capability: &str) -> Self {
228        Self::Failed(format!("{capability} is not supported by this host"))
229    }
230
231    /// Wraps an I/O error.
232    #[must_use]
233    pub fn io(err: &std::io::Error) -> Self {
234        Self::Io(err.to_string())
235    }
236}
237
238/// Failure of a guest call (`ExtensionError{extension, call, message, trap}`).
239#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
240#[error("{message}")]
241pub struct ExtensionError {
242    /// Extension id (or path when the id is unknown).
243    pub extension: String,
244    /// Guest call (`init`, `on-event:tool_call`, `run-tool:bash`, …).
245    pub call: String,
246    /// Error text.
247    pub message: String,
248    /// The guest trapped (instance restarts lazily).
249    pub trap: bool,
250}
251
252impl ExtensionError {
253    /// A non-trap error.
254    #[must_use]
255    pub fn new(
256        extension: impl Into<String>,
257        call: impl Into<String>,
258        message: impl Into<String>,
259    ) -> Self {
260        Self {
261            extension: extension.into(),
262            call: call.into(),
263            message: message.into(),
264            trap: false,
265        }
266    }
267
268    /// A trap.
269    #[must_use]
270    pub fn trap(
271        extension: impl Into<String>,
272        call: impl Into<String>,
273        message: impl Into<String>,
274    ) -> Self {
275        Self {
276            trap: true,
277            ..Self::new(extension, call, message)
278        }
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn id_grammar() {
288        for ok in ["a", "acme.background-tasks", "oino.openai-compat", "x1.y-2"] {
289            assert!(ExtensionId::new(ok).is_ok(), "{ok}");
290        }
291        for bad in ["", "A", "-a", "a..b", "a.", ".a", "a_b", "a/b"] {
292            assert!(ExtensionId::new(bad).is_err(), "{bad}");
293        }
294    }
295
296    #[test]
297    fn with_kind_keeps_tool_id_only_for_tools() {
298        let cx = CallContext {
299            tool_call_id: Some("t1".into()),
300            kind: CallKind::Tool,
301            ..CallContext::default()
302        };
303        assert_eq!(cx.with_kind(CallKind::Event).tool_call_id, None);
304        assert_eq!(
305            cx.with_kind(CallKind::Tool).tool_call_id.as_deref(),
306            Some("t1")
307        );
308        assert!(CallKind::Shortcut.is_command_context());
309    }
310}