use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use crate::error::AdapterError;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HookEvent {
SessionStart,
SessionEnd,
UserPromptSubmit,
PreToolUse,
PostToolUse,
Stop,
SubagentStart,
SubagentStop,
PermissionRequest,
Notification,
PostCompaction,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HookIntent {
#[default]
Enforce,
Inject,
Rewrite,
Notify,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HookDecl {
pub event: HookEvent,
pub command: String,
#[serde(default)]
pub intent: HookIntent,
#[serde(default)]
pub matcher: Option<String>,
#[serde(default)]
pub timeout: Option<u64>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RuleDecl {
pub name: String,
pub content: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SkillDecl {
pub name: String,
pub content: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CommandDecl {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub content: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SubagentDecl {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub effort: Option<String>,
#[serde(default)]
pub sandbox: Option<String>,
pub instructions: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginDecl {
pub id: String,
#[serde(default)]
pub source: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PermissionDecl {
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub ask: Vec<String>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MechanismSet {
#[serde(default)]
pub hooks: Vec<HookDecl>,
#[serde(default)]
pub rules: Vec<RuleDecl>,
#[serde(default)]
pub skills: Vec<SkillDecl>,
#[serde(default)]
pub commands: Vec<CommandDecl>,
#[serde(default)]
pub subagents: Vec<SubagentDecl>,
#[serde(default)]
pub plugins: Vec<PluginDecl>,
#[serde(default)]
pub permissions: Option<PermissionDecl>,
#[serde(default)]
pub mcp_servers: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub native_profile: Option<String>,
}
impl HookEvent {
pub fn kebab_name(self) -> String {
match serde_json::to_value(self) {
Ok(serde_json::Value::String(name)) => name,
_ => unreachable!("unit variant serializes to its rename string"),
}
}
}
impl MechanismSet {
pub fn merge(&mut self, other: MechanismSet) {
merge_named(&mut self.rules, other.rules, |d| &d.name);
merge_named(&mut self.skills, other.skills, |d| &d.name);
merge_named(&mut self.commands, other.commands, |d| &d.name);
merge_named(&mut self.subagents, other.subagents, |d| &d.name);
merge_named(&mut self.plugins, other.plugins, |d| &d.id);
for hook in other.hooks {
if !self.hooks.iter().any(|h| {
h.event == hook.event && h.command == hook.command && h.matcher == hook.matcher
}) {
self.hooks.push(hook);
}
}
if let Some(permissions) = other.permissions {
self.permissions = Some(permissions);
}
self.mcp_servers.extend(other.mcp_servers);
if let Some(native) = other.native_profile {
self.native_profile = Some(native);
}
}
pub fn validate(&self) -> Result<(), AdapterError> {
let malformed = |item: &str| {
AdapterError::permanent("mechanism_malformed", "mechanisms/validate")
.with_detail(item.to_owned())
};
let path_safe = |name: &str| {
!name.is_empty()
&& name != ".."
&& name != "."
&& !name.contains(['/', '\\'])
&& name.chars().all(|c| !c.is_control())
};
let mut names = BTreeMap::new();
for rule in &self.rules {
if rule.name.trim().is_empty() || rule.content.trim().is_empty() {
return Err(malformed("rule requires a non-empty name and content"));
}
if !path_safe(&rule.name) {
return Err(malformed("rule name is not path-safe"));
}
if names.insert(format!("rules:{}", rule.name), ()).is_some() {
return Err(malformed("duplicate rule name"));
}
}
for skill in &self.skills {
if skill.name.trim().is_empty() || skill.content.trim().is_empty() {
return Err(malformed("skill requires a non-empty name and content"));
}
if !path_safe(&skill.name) {
return Err(malformed("skill name is not path-safe"));
}
if names.insert(format!("skills:{}", skill.name), ()).is_some() {
return Err(malformed("duplicate skill name"));
}
}
for command in &self.commands {
if command.name.trim().is_empty() || command.content.trim().is_empty() {
return Err(malformed("command requires a non-empty name and content"));
}
if !path_safe(&command.name) {
return Err(malformed("command name is not path-safe"));
}
if names
.insert(format!("commands:{}", command.name), ())
.is_some()
{
return Err(malformed("duplicate command name"));
}
}
for subagent in &self.subagents {
if subagent.name.trim().is_empty() || subagent.instructions.trim().is_empty() {
return Err(malformed(
"subagent requires a non-empty name and instructions",
));
}
if !path_safe(&subagent.name) {
return Err(malformed("subagent name is not path-safe"));
}
if names
.insert(format!("subagents:{}", subagent.name), ())
.is_some()
{
return Err(malformed("duplicate subagent name"));
}
}
for plugin in &self.plugins {
if plugin.id.trim().is_empty() {
return Err(malformed("plugin requires a non-empty id"));
}
if names.insert(format!("plugins:{}", plugin.id), ()).is_some() {
return Err(malformed("duplicate plugin id"));
}
}
for hook in &self.hooks {
if hook.command.trim().is_empty() {
return Err(malformed("hook requires a non-empty command"));
}
}
for name in self.mcp_servers.keys() {
if name.trim().is_empty() {
return Err(malformed("mcp server names must be non-empty"));
}
}
if let Some(native) = &self.native_profile {
if native.trim().is_empty() {
return Err(malformed("nativeProfile must be non-empty"));
}
}
Ok(())
}
}
fn merge_named<T>(base: &mut Vec<T>, overlay: Vec<T>, key: impl Fn(&T) -> &str) {
for item in overlay {
match base.iter_mut().find(|existing| key(existing) == key(&item)) {
Some(existing) => *existing = item,
None => base.push(item),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ArtifactScope {
UserHome,
Project,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum WriteMode {
Create,
JsonMerge { at: String },
ManagedBlock { name: String },
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Artifact {
pub scope: ArtifactScope,
pub path: PathBuf,
pub content: String,
pub mode: WriteMode,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Support {
Native,
Adapted,
Unsupported,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SupportEntry {
pub item: String,
pub support: Support,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AdaptationPlan {
pub dialect: String,
#[serde(default)]
pub artifacts: Vec<Artifact>,
#[serde(default)]
pub report: Vec<SupportEntry>,
}
impl AdaptationPlan {
pub(crate) fn push(
&mut self,
scope: ArtifactScope,
path: &str,
content: String,
mode: WriteMode,
) {
self.artifacts.push(Artifact {
scope,
path: PathBuf::from(path),
content,
mode,
});
}
pub(crate) fn report(&mut self, item: impl Into<String>, support: Support, note: Option<&str>) {
self.report.push(SupportEntry {
item: item.into(),
support,
note: note.map(str::to_owned),
});
}
}