use std::fmt;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ExtensionId(String);
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("invalid extension id `{0}`: expected lowercase dot-separated segments of [a-z0-9-]")]
pub struct InvalidExtensionId(pub String);
impl ExtensionId {
pub fn new(id: impl Into<String>) -> Result<Self, InvalidExtensionId> {
let id = id.into();
if is_valid_id(&id) {
Ok(Self(id))
} else {
Err(InvalidExtensionId(id))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
fn is_valid_id(id: &str) -> bool {
!id.is_empty()
&& id.split('.').all(|segment| {
let mut chars = segment.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
})
}
impl TryFrom<String> for ExtensionId {
type Error = InvalidExtensionId;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ExtensionId> for String {
fn from(value: ExtensionId) -> Self {
value.0
}
}
impl fmt::Display for ExtensionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for ExtensionId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Tui,
Rpc,
Json,
#[default]
Print,
Acp,
Desktop,
Mobile,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CallKind {
Init,
#[default]
Event,
Tool,
Command,
Shortcut,
Service,
Nudge,
Render,
Route,
Provider,
}
impl CallKind {
#[must_use]
pub fn is_command_context(self) -> bool {
matches!(self, Self::Command | Self::Shortcut)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CallContext {
pub session_id: String,
pub mode: Mode,
pub has_ui: bool,
pub cwd: PathBuf,
pub is_child: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub child_role: Option<String>,
pub kind: CallKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
impl CallContext {
#[must_use]
pub fn with_kind(&self, kind: CallKind) -> Self {
Self {
kind,
tool_call_id: if kind == CallKind::Tool {
self.tool_call_id.clone()
} else {
None
},
..self.clone()
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum HostError {
#[error("permission denied: {0}")]
Denied(String),
#[error(
"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()."
)]
StaleContext,
#[error("This operation is only available in command and shortcut contexts")]
CommandContextRequired,
#[error("{0}")]
InvalidArgument(String),
#[error("not found: {0}")]
NotFound(String),
#[error("timed out")]
Timeout,
#[error("cancelled")]
Cancelled,
#[error("{0}")]
Io(String),
#[error("{0}")]
Failed(String),
}
impl HostError {
#[must_use]
pub fn unsupported(capability: &str) -> Self {
Self::Failed(format!("{capability} is not supported by this host"))
}
#[must_use]
pub fn io(err: &std::io::Error) -> Self {
Self::Io(err.to_string())
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct ExtensionError {
pub extension: String,
pub call: String,
pub message: String,
pub trap: bool,
}
impl ExtensionError {
#[must_use]
pub fn new(
extension: impl Into<String>,
call: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
extension: extension.into(),
call: call.into(),
message: message.into(),
trap: false,
}
}
#[must_use]
pub fn trap(
extension: impl Into<String>,
call: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
trap: true,
..Self::new(extension, call, message)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_grammar() {
for ok in ["a", "acme.background-tasks", "oino.openai-compat", "x1.y-2"] {
assert!(ExtensionId::new(ok).is_ok(), "{ok}");
}
for bad in ["", "A", "-a", "a..b", "a.", ".a", "a_b", "a/b"] {
assert!(ExtensionId::new(bad).is_err(), "{bad}");
}
}
#[test]
fn with_kind_keeps_tool_id_only_for_tools() {
let cx = CallContext {
tool_call_id: Some("t1".into()),
kind: CallKind::Tool,
..CallContext::default()
};
assert_eq!(cx.with_kind(CallKind::Event).tool_call_id, None);
assert_eq!(
cx.with_kind(CallKind::Tool).tool_call_id.as_deref(),
Some("t1")
);
assert!(CallKind::Shortcut.is_command_context());
}
}