1use std::fmt;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(try_from = "String", into = "String")]
11pub struct ExtensionId(String);
12
13#[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 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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "lowercase")]
79pub enum LogLevel {
80 Trace,
82 Debug,
84 Info,
86 Warn,
88 Error,
90}
91
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
94#[serde(rename_all = "lowercase")]
95pub enum Mode {
96 Tui,
98 Rpc,
100 Json,
102 #[default]
104 Print,
105 Acp,
107 Desktop,
109 Mobile,
111}
112
113#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum CallKind {
117 Init,
119 #[default]
121 Event,
122 Tool,
124 Command,
126 Shortcut,
128 Service,
130 Nudge,
132 Render,
134 Route,
136 Provider,
138}
139
140impl CallKind {
141 #[must_use]
143 pub fn is_command_context(self) -> bool {
144 matches!(self, Self::Command | Self::Shortcut)
145 }
146}
147
148#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct CallContext {
153 pub session_id: String,
155 pub mode: Mode,
157 pub has_ui: bool,
159 pub cwd: PathBuf,
161 pub is_child: bool,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub child_role: Option<String>,
166 pub kind: CallKind,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub tool_call_id: Option<String>,
171}
172
173impl CallContext {
174 #[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#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
191#[non_exhaustive]
192pub enum HostError {
193 #[error("permission denied: {0}")]
195 Denied(String),
196 #[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 #[error("This operation is only available in command and shortcut contexts")]
203 CommandContextRequired,
204 #[error("{0}")]
206 InvalidArgument(String),
207 #[error("not found: {0}")]
209 NotFound(String),
210 #[error("timed out")]
212 Timeout,
213 #[error("cancelled")]
215 Cancelled,
216 #[error("{0}")]
218 Io(String),
219 #[error("{0}")]
221 Failed(String),
222}
223
224impl HostError {
225 #[must_use]
227 pub fn unsupported(capability: &str) -> Self {
228 Self::Failed(format!("{capability} is not supported by this host"))
229 }
230
231 #[must_use]
233 pub fn io(err: &std::io::Error) -> Self {
234 Self::Io(err.to_string())
235 }
236}
237
238#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
240#[error("{message}")]
241pub struct ExtensionError {
242 pub extension: String,
244 pub call: String,
246 pub message: String,
248 pub trap: bool,
250}
251
252impl ExtensionError {
253 #[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 #[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}