use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;
use crate::tool_inputs::AskUserQuestionInput;
// ============================================================================
// Permission Enums
// ============================================================================
/// The type of a permission grant.
///
/// Determines whether the permission adds rules for specific tools
/// or sets a broad mode.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PermissionType {
/// Add fine-grained rules for specific tools.
AddRules,
/// Set a broad permission mode (e.g., accept all edits).
SetMode,
/// A type not yet known to this version of the crate.
Unknown(String),
}
impl PermissionType {
pub fn as_str(&self) -> &str {
match self {
Self::AddRules => "addRules",
Self::SetMode => "setMode",
Self::Unknown(s) => s.as_str(),
}
}
}
impl fmt::Display for PermissionType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&str> for PermissionType {
fn from(s: &str) -> Self {
match s {
"addRules" => Self::AddRules,
"setMode" => Self::SetMode,
other => Self::Unknown(other.to_string()),
}
}
}
impl Serialize for PermissionType {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for PermissionType {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Self::from(s.as_str()))
}
}
/// Where a permission applies.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PermissionDestination {
/// Applies only to the current session.
Session,
/// Persists across sessions for the project.
Project,
/// A destination not yet known to this version of the crate.
Unknown(String),
}
impl PermissionDestination {
pub fn as_str(&self) -> &str {
match self {
Self::Session => "session",
Self::Project => "project",
Self::Unknown(s) => s.as_str(),
}
}
}
impl fmt::Display for PermissionDestination {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&str> for PermissionDestination {
fn from(s: &str) -> Self {
match s {
"session" => Self::Session,
"project" => Self::Project,
other => Self::Unknown(other.to_string()),
}
}
}
impl Serialize for PermissionDestination {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for PermissionDestination {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Self::from(s.as_str()))
}
}
/// The behavior of a permission rule.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PermissionBehavior {
/// Allow the tool action.
Allow,
/// Deny the tool action.
Deny,
/// A behavior not yet known to this version of the crate.
Unknown(String),
}
impl PermissionBehavior {
pub fn as_str(&self) -> &str {
match self {
Self::Allow => "allow",
Self::Deny => "deny",
Self::Unknown(s) => s.as_str(),
}
}
}
impl fmt::Display for PermissionBehavior {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&str> for PermissionBehavior {
fn from(s: &str) -> Self {
match s {
"allow" => Self::Allow,
"deny" => Self::Deny,
other => Self::Unknown(other.to_string()),
}
}
}
impl Serialize for PermissionBehavior {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for PermissionBehavior {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Self::from(s.as_str()))
}
}
/// Named permission modes that can be set via `setMode`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PermissionModeName {
/// Accept all file edits without prompting.
AcceptEdits,
/// Bypass all permission checks.
BypassPermissions,
/// A mode not yet known to this version of the crate.
Unknown(String),
}
impl PermissionModeName {
pub fn as_str(&self) -> &str {
match self {
Self::AcceptEdits => "acceptEdits",
Self::BypassPermissions => "bypassPermissions",
Self::Unknown(s) => s.as_str(),
}
}
}
impl fmt::Display for PermissionModeName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&str> for PermissionModeName {
fn from(s: &str) -> Self {
match s {
"acceptEdits" => Self::AcceptEdits,
"bypassPermissions" => Self::BypassPermissions,
other => Self::Unknown(other.to_string()),
}
}
}
impl Serialize for PermissionModeName {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for PermissionModeName {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Self::from(s.as_str()))
}
}
// ============================================================================
// Control Protocol Types (for bidirectional tool approval)
// ============================================================================
/// Control request from CLI (tool permission requests, hooks, etc.)
///
/// When using `--permission-prompt-tool stdio`, the CLI sends these requests
/// asking for approval before executing tools. The SDK must respond with a
/// [`ControlResponse`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlRequest {
/// Unique identifier for this request (used to correlate responses)
pub request_id: String,
/// The request payload
pub request: ControlRequestPayload,
}
/// Control request payload variants
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "subtype", rename_all = "snake_case")]
pub enum ControlRequestPayload {
/// Tool permission request - Claude wants to use a tool
CanUseTool(ToolPermissionRequest),
/// Hook callback request
HookCallback(HookCallbackRequest),
/// MCP message request
McpMessage(McpMessageRequest),
/// Initialize request (sent by SDK to CLI)
Initialize(InitializeRequest),
}
/// A permission to grant for "remember this decision" functionality.
///
/// When responding to a tool permission request, you can include permissions
/// that should be granted to avoid repeated prompts for similar actions.
///
/// # Example
///
/// ```
/// use claude_codes::{Permission, PermissionModeName, PermissionDestination};
///
/// // Grant permission for a specific bash command
/// let perm = Permission::allow_tool("Bash", "npm test");
///
/// // Grant permission to set a mode for the session
/// let mode_perm = Permission::set_mode(PermissionModeName::AcceptEdits, PermissionDestination::Session);
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Permission {
/// The type of permission (e.g., addRules, setMode)
#[serde(rename = "type")]
pub permission_type: PermissionType,
/// Where to apply this permission (e.g., session, project)
pub destination: PermissionDestination,
/// The permission mode (for setMode type)
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<PermissionModeName>,
/// The behavior (for addRules type, e.g., allow, deny)
#[serde(skip_serializing_if = "Option::is_none")]
pub behavior: Option<PermissionBehavior>,
/// The rules to add (for addRules type)
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<Vec<PermissionRule>>,
}
/// A rule within a permission grant.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PermissionRule {
/// The name of the tool this rule applies to
#[serde(rename = "toolName")]
pub tool_name: String,
/// The rule content (glob pattern or command pattern)
#[serde(rename = "ruleContent")]
pub rule_content: String,
}
impl Permission {
/// Create a permission to allow a specific tool with a rule pattern.
///
/// # Example
/// ```
/// use claude_codes::Permission;
///
/// // Allow "npm test" bash command for this session
/// let perm = Permission::allow_tool("Bash", "npm test");
///
/// // Allow reading from /tmp directory
/// let read_perm = Permission::allow_tool("Read", "/tmp/**");
/// ```
pub fn allow_tool(tool_name: impl Into<String>, rule_content: impl Into<String>) -> Self {
Permission {
permission_type: PermissionType::AddRules,
destination: PermissionDestination::Session,
mode: None,
behavior: Some(PermissionBehavior::Allow),
rules: Some(vec![PermissionRule {
tool_name: tool_name.into(),
rule_content: rule_content.into(),
}]),
}
}
/// Create a permission to allow a tool with a specific destination.
///
/// # Example
/// ```
/// use claude_codes::{Permission, PermissionDestination};
///
/// // Allow for the entire project, not just session
/// let perm = Permission::allow_tool_with_destination("Bash", "npm test", PermissionDestination::Project);
/// ```
pub fn allow_tool_with_destination(
tool_name: impl Into<String>,
rule_content: impl Into<String>,
destination: PermissionDestination,
) -> Self {
Permission {
permission_type: PermissionType::AddRules,
destination,
mode: None,
behavior: Some(PermissionBehavior::Allow),
rules: Some(vec![PermissionRule {
tool_name: tool_name.into(),
rule_content: rule_content.into(),
}]),
}
}
/// Create a permission to set a mode (like acceptEdits or bypassPermissions).
///
/// # Example
/// ```
/// use claude_codes::{Permission, PermissionModeName, PermissionDestination};
///
/// // Accept all edits for this session
/// let perm = Permission::set_mode(PermissionModeName::AcceptEdits, PermissionDestination::Session);
/// ```
pub fn set_mode(mode: PermissionModeName, destination: PermissionDestination) -> Self {
Permission {
permission_type: PermissionType::SetMode,
destination,
mode: Some(mode),
behavior: None,
rules: None,
}
}
/// Create a permission from a PermissionSuggestion.
///
/// This is useful when you want to grant a permission that Claude suggested.
///
/// # Example
/// ```
/// use claude_codes::{Permission, PermissionSuggestion, PermissionType, PermissionDestination, PermissionModeName};
///
/// // Convert a suggestion to a permission for the response
/// let suggestion = PermissionSuggestion {
/// suggestion_type: PermissionType::SetMode,
/// destination: PermissionDestination::Session,
/// mode: Some(PermissionModeName::AcceptEdits),
/// behavior: None,
/// rules: None,
/// };
/// let perm = Permission::from_suggestion(&suggestion);
/// ```
pub fn from_suggestion(suggestion: &PermissionSuggestion) -> Self {
Permission {
permission_type: suggestion.suggestion_type.clone(),
destination: suggestion.destination.clone(),
mode: suggestion.mode.clone(),
behavior: suggestion.behavior.clone(),
rules: suggestion.rules.as_ref().map(|rules| {
rules
.iter()
.filter_map(|v| {
Some(PermissionRule {
tool_name: v.get("toolName")?.as_str()?.to_string(),
rule_content: v.get("ruleContent")?.as_str()?.to_string(),
})
})
.collect()
}),
}
}
}
/// A suggested permission for tool approval.
///
/// When Claude requests tool permission, it may include suggestions for
/// permissions that could be granted to avoid repeated prompts for similar
/// actions. The format varies based on the suggestion type:
///
/// - `setMode`: `{"type": "setMode", "mode": "acceptEdits", "destination": "session"}`
/// - `addRules`: `{"type": "addRules", "rules": [...], "behavior": "allow", "destination": "session"}`
///
/// Use the helper methods to access common fields.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PermissionSuggestion {
/// The type of suggestion (e.g., setMode, addRules)
#[serde(rename = "type")]
pub suggestion_type: PermissionType,
/// Where to apply this permission (e.g., session, project)
pub destination: PermissionDestination,
/// The permission mode (for setMode type)
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<PermissionModeName>,
/// The behavior (for addRules type, e.g., allow)
#[serde(skip_serializing_if = "Option::is_none")]
pub behavior: Option<PermissionBehavior>,
/// The rules to add (for addRules type)
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<Vec<Value>>,
}
/// Tool permission request details
///
/// This is sent when Claude wants to use a tool. The SDK should evaluate
/// the request and respond with allow/deny using the ergonomic builder methods.
///
/// # Example
///
/// ```
/// use claude_codes::{ToolPermissionRequest, ControlResponse};
/// use serde_json::json;
///
/// fn handle_permission(req: &ToolPermissionRequest, request_id: &str) -> ControlResponse {
/// // Block dangerous bash commands
/// if req.tool_name == "Bash" {
/// if let Some(cmd) = req.input.get("command").and_then(|v| v.as_str()) {
/// if cmd.contains("rm -rf") {
/// return req.deny("Dangerous command blocked", request_id);
/// }
/// }
/// }
///
/// // Allow everything else
/// req.allow(request_id)
/// }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolPermissionRequest {
/// Name of the tool Claude wants to use (e.g., "Bash", "Write", "Read")
pub tool_name: String,
/// Input parameters for the tool
pub input: Value,
/// Suggested permissions that could be granted to avoid repeated prompts
#[serde(default)]
pub permission_suggestions: Vec<PermissionSuggestion>,
/// Path that was blocked (if this is a retry after path-based denial)
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked_path: Option<String>,
/// Reason why this tool use requires approval
#[serde(skip_serializing_if = "Option::is_none")]
pub decision_reason: Option<String>,
/// The tool use ID for this request
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_use_id: Option<String>,
}
impl ToolPermissionRequest {
/// Allow the tool to execute with its original input.
///
/// # Example
/// ```
/// # use claude_codes::ToolPermissionRequest;
/// # use serde_json::json;
/// let req = ToolPermissionRequest {
/// tool_name: "Read".to_string(),
/// input: json!({"file_path": "/tmp/test.txt"}),
/// permission_suggestions: vec![],
/// blocked_path: None,
/// decision_reason: None,
/// tool_use_id: None,
/// };
/// let response = req.allow("req-123");
/// ```
pub fn allow(&self, request_id: &str) -> ControlResponse {
ControlResponse::from_result(request_id, PermissionResult::allow(self.input.clone()))
}
/// Allow the tool to execute with modified input.
///
/// Use this to sanitize or redirect tool inputs. For example, redirecting
/// file writes to a safe directory.
///
/// # Example
/// ```
/// # use claude_codes::ToolPermissionRequest;
/// # use serde_json::json;
/// let req = ToolPermissionRequest {
/// tool_name: "Write".to_string(),
/// input: json!({"file_path": "/etc/passwd", "content": "test"}),
/// permission_suggestions: vec![],
/// blocked_path: None,
/// decision_reason: None,
/// tool_use_id: None,
/// };
/// // Redirect to safe location
/// let safe_input = json!({"file_path": "/tmp/safe/passwd", "content": "test"});
/// let response = req.allow_with(safe_input, "req-123");
/// ```
pub fn allow_with(&self, modified_input: Value, request_id: &str) -> ControlResponse {
ControlResponse::from_result(request_id, PermissionResult::allow(modified_input))
}
/// Allow with updated permissions list (raw JSON Values).
///
/// Prefer using `allow_and_remember` for type safety.
pub fn allow_with_permissions(
&self,
modified_input: Value,
permissions: Vec<Value>,
request_id: &str,
) -> ControlResponse {
ControlResponse::from_result(
request_id,
PermissionResult::allow_with_permissions(modified_input, permissions),
)
}
/// Allow the tool and grant permissions for "remember this decision".
///
/// This is the ergonomic way to allow a tool while also granting permissions
/// so similar actions won't require approval in the future.
///
/// # Example
/// ```
/// use claude_codes::{ToolPermissionRequest, Permission};
/// use serde_json::json;
///
/// let req = ToolPermissionRequest {
/// tool_name: "Bash".to_string(),
/// input: json!({"command": "npm test"}),
/// permission_suggestions: vec![],
/// blocked_path: None,
/// decision_reason: None,
/// tool_use_id: None,
/// };
///
/// // Allow and remember this decision for the session
/// let response = req.allow_and_remember(
/// vec![Permission::allow_tool("Bash", "npm test")],
/// "req-123",
/// );
/// ```
pub fn allow_and_remember(
&self,
permissions: Vec<Permission>,
request_id: &str,
) -> ControlResponse {
ControlResponse::from_result(
request_id,
PermissionResult::allow_with_typed_permissions(self.input.clone(), permissions),
)
}
/// Allow the tool with modified input and grant permissions.
///
/// Combines input modification with "remember this decision" functionality.
pub fn allow_with_and_remember(
&self,
modified_input: Value,
permissions: Vec<Permission>,
request_id: &str,
) -> ControlResponse {
ControlResponse::from_result(
request_id,
PermissionResult::allow_with_typed_permissions(modified_input, permissions),
)
}
/// Allow the tool and remember using the first permission suggestion.
///
/// This is a convenience method for the common case of accepting Claude's
/// first suggested permission (usually the most relevant one).
///
/// Returns `None` if there are no permission suggestions.
///
/// # Example
/// ```
/// use claude_codes::ToolPermissionRequest;
/// use serde_json::json;
///
/// let req = ToolPermissionRequest {
/// tool_name: "Bash".to_string(),
/// input: json!({"command": "npm test"}),
/// permission_suggestions: vec![], // Would have suggestions in real use
/// blocked_path: None,
/// decision_reason: None,
/// tool_use_id: None,
/// };
///
/// // Try to allow with first suggestion, or just allow without remembering
/// let response = req.allow_and_remember_suggestion("req-123")
/// .unwrap_or_else(|| req.allow("req-123"));
/// ```
pub fn allow_and_remember_suggestion(&self, request_id: &str) -> Option<ControlResponse> {
self.permission_suggestions.first().map(|suggestion| {
let perm = Permission::from_suggestion(suggestion);
self.allow_and_remember(vec![perm], request_id)
})
}
/// Deny the tool execution.
///
/// The message will be shown to Claude, who may try a different approach.
///
/// # Example
/// ```
/// # use claude_codes::ToolPermissionRequest;
/// # use serde_json::json;
/// let req = ToolPermissionRequest {
/// tool_name: "Bash".to_string(),
/// input: json!({"command": "sudo rm -rf /"}),
/// permission_suggestions: vec![],
/// blocked_path: None,
/// decision_reason: None,
/// tool_use_id: None,
/// };
/// let response = req.deny("Dangerous command blocked by policy", "req-123");
/// ```
pub fn deny(&self, message: impl Into<String>, request_id: &str) -> ControlResponse {
ControlResponse::from_result(request_id, PermissionResult::deny(message))
}
/// Deny the tool execution and stop the entire session.
///
/// Use this for severe policy violations that should halt all processing.
pub fn deny_and_stop(&self, message: impl Into<String>, request_id: &str) -> ControlResponse {
ControlResponse::from_result(request_id, PermissionResult::deny_and_interrupt(message))
}
/// Build an `allow` response for an `AskUserQuestion` permission request
/// by supplying the user's chosen answers.
///
/// The CLI's `AskUserQuestion` tool has a finicky wire contract that
/// is easy to get wrong by hand:
///
/// - The response's `input` must preserve the original `questions`
/// array. Stripping it (sending just `{"answers": ...}`) makes the
/// downstream viewer crash with
/// `undefined is not an object (evaluating 'q.map')` (the CLI echoes
/// the `updatedInput` we return into `tool_use_result`, and viewers
/// read `tool_use_result.questions`).
/// - The `answers` map must be keyed by the full **question text**
/// (the `question` field). Keying by `header` or by question index
/// makes the CLI's tool render `"Your questions have been answered: ."`
/// with an empty answer body, leaving Claude unable to see which
/// option the user picked.
///
/// This helper does both correctly: it parses `self.input` as
/// [`AskUserQuestionInput`](crate::AskUserQuestionInput), looks each
/// answer up by question index, and inserts the answer keyed by
/// `q.question`. The original `questions` (and any `metadata`) pass
/// through unchanged.
///
/// `answers_by_index` maps 0-based question index into the original
/// `questions` array to the user's chosen answer label. For
/// multi-select questions, join the selected labels with `", "`.
/// Questions not in the map are treated as unanswered.
///
/// Non-`AskUserQuestion` callers should keep using
/// [`allow`](Self::allow) / [`allow_with`](Self::allow_with).
///
/// # Errors
///
/// Returns [`AskUserQuestionResponseError`] if the request isn't an
/// `AskUserQuestion` request, the input doesn't parse, or an answer
/// references a question index that doesn't exist.
///
/// # Example
///
/// ```no_run
/// use claude_codes::ToolPermissionRequest;
/// use std::collections::HashMap;
///
/// fn answer(req: &ToolPermissionRequest, request_id: &str) {
/// let mut answers = HashMap::new();
/// answers.insert(0_usize, "Blue".to_string());
/// let response = req
/// .answer_questions(&answers, request_id)
/// .expect("request is AskUserQuestion-shaped");
/// // send `response` back to the CLI
/// }
/// ```
pub fn answer_questions(
&self,
answers_by_index: &HashMap<usize, String>,
request_id: &str,
) -> Result<ControlResponse, AskUserQuestionResponseError> {
if self.tool_name != "AskUserQuestion" {
return Err(AskUserQuestionResponseError::WrongTool(
self.tool_name.clone(),
));
}
let parsed: AskUserQuestionInput = serde_json::from_value(self.input.clone())
.map_err(AskUserQuestionResponseError::ParseInput)?;
let total = parsed.questions.len();
let mut answers_map = serde_json::Map::new();
for (idx, answer) in answers_by_index {
let q = parsed.questions.get(*idx).ok_or(
AskUserQuestionResponseError::QuestionIndexOutOfRange { index: *idx, total },
)?;
answers_map.insert(q.question.clone(), Value::String(answer.clone()));
}
let mut updated_input = self.input.clone();
// Input is an object — we just deserialized it as `AskUserQuestionInput`.
updated_input
.as_object_mut()
.expect("AskUserQuestion input is a JSON object")
.insert("answers".to_string(), Value::Object(answers_map));
Ok(ControlResponse::from_result(
request_id,
PermissionResult::allow(updated_input),
))
}
}
/// Errors that can occur when building an `AskUserQuestion` response via
/// [`ToolPermissionRequest::answer_questions`].
#[derive(Debug, thiserror::Error)]
pub enum AskUserQuestionResponseError {
/// The request was for a different tool, not `AskUserQuestion`.
#[error("expected tool_name=AskUserQuestion, got `{0}`")]
WrongTool(String),
/// The `input` field didn't deserialize into `AskUserQuestionInput`.
#[error("failed to parse AskUserQuestion input: {0}")]
ParseInput(#[source] serde_json::Error),
/// An answer entry referenced a question index outside the questions array.
#[error("answer references question index {index}, but only {total} question(s) were asked")]
QuestionIndexOutOfRange {
/// The out-of-range index the caller supplied.
index: usize,
/// The number of questions actually in the request.
total: usize,
},
}
/// Result of a permission decision
///
/// This type represents the decision made by the permission callback.
/// It can be serialized directly into the control response format.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "behavior", rename_all = "snake_case")]
pub enum PermissionResult {
/// Allow the tool to execute
Allow {
/// The (possibly modified) input to pass to the tool
#[serde(rename = "updatedInput")]
updated_input: Value,
/// Optional updated permissions list
#[serde(rename = "updatedPermissions", skip_serializing_if = "Option::is_none")]
updated_permissions: Option<Vec<Value>>,
},
/// Deny the tool execution
Deny {
/// Message explaining why the tool was denied
message: String,
/// If true, stop the entire session
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
interrupt: bool,
},
}
impl PermissionResult {
/// Create an allow result with the given input
pub fn allow(input: Value) -> Self {
PermissionResult::Allow {
updated_input: input,
updated_permissions: None,
}
}
/// Create an allow result with raw permissions (as JSON Values).
///
/// Prefer using `allow_with_typed_permissions` for type safety.
pub fn allow_with_permissions(input: Value, permissions: Vec<Value>) -> Self {
PermissionResult::Allow {
updated_input: input,
updated_permissions: Some(permissions),
}
}
/// Create an allow result with typed permissions.
///
/// This is the preferred way to grant permissions for "remember this decision"
/// functionality.
///
/// # Example
/// ```
/// use claude_codes::{Permission, PermissionResult};
/// use serde_json::json;
///
/// let result = PermissionResult::allow_with_typed_permissions(
/// json!({"command": "npm test"}),
/// vec![Permission::allow_tool("Bash", "npm test")],
/// );
/// ```
pub fn allow_with_typed_permissions(input: Value, permissions: Vec<Permission>) -> Self {
let permission_values: Vec<Value> = permissions
.into_iter()
.filter_map(|p| serde_json::to_value(p).ok())
.collect();
PermissionResult::Allow {
updated_input: input,
updated_permissions: Some(permission_values),
}
}
/// Create a deny result
pub fn deny(message: impl Into<String>) -> Self {
PermissionResult::Deny {
message: message.into(),
interrupt: false,
}
}
/// Create a deny result that also interrupts the session
pub fn deny_and_interrupt(message: impl Into<String>) -> Self {
PermissionResult::Deny {
message: message.into(),
interrupt: true,
}
}
}
/// Hook callback request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookCallbackRequest {
pub callback_id: String,
pub input: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_use_id: Option<String>,
}
/// MCP message request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpMessageRequest {
pub server_name: String,
pub message: Value,
}
/// Initialize request (SDK -> CLI)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InitializeRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub hooks: Option<Value>,
}
/// Control response to CLI
///
/// Built using the ergonomic methods on [`ToolPermissionRequest`] or
/// constructed directly for other control request types.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlResponse {
/// The request ID this response corresponds to
pub response: ControlResponsePayload,
}
impl ControlResponse {
/// Create a success response from a PermissionResult
///
/// This is the preferred way to construct permission responses.
pub fn from_result(request_id: &str, result: PermissionResult) -> Self {
// Serialize the PermissionResult to Value for the response
let response_value = serde_json::to_value(&result)
.expect("PermissionResult serialization should never fail");
ControlResponse {
response: ControlResponsePayload::Success {
request_id: request_id.to_string(),
response: Some(response_value),
},
}
}
/// Create a success response with the given payload (raw Value)
pub fn success(request_id: &str, response_data: Value) -> Self {
ControlResponse {
response: ControlResponsePayload::Success {
request_id: request_id.to_string(),
response: Some(response_data),
},
}
}
/// Create an empty success response (for acks)
pub fn success_empty(request_id: &str) -> Self {
ControlResponse {
response: ControlResponsePayload::Success {
request_id: request_id.to_string(),
response: None,
},
}
}
/// Create an error response
pub fn error(request_id: &str, error_message: impl Into<String>) -> Self {
ControlResponse {
response: ControlResponsePayload::Error {
request_id: request_id.to_string(),
error: error_message.into(),
},
}
}
}
/// Control response payload
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "subtype", rename_all = "snake_case")]
pub enum ControlResponsePayload {
Success {
request_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
response: Option<Value>,
},
Error {
request_id: String,
error: String,
},
}
/// Wrapper for outgoing control responses (includes type tag)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlResponseMessage {
#[serde(rename = "type")]
pub message_type: String,
pub response: ControlResponsePayload,
}
impl From<ControlResponse> for ControlResponseMessage {
fn from(resp: ControlResponse) -> Self {
ControlResponseMessage {
message_type: "control_response".to_string(),
response: resp.response,
}
}
}
/// SDK control message to gracefully interrupt a running Claude session.
///
/// When written to the CLI subprocess's stdin, this tells Claude to stop its
/// current response and return control to the caller without killing the session.
///
/// This corresponds to the TypeScript SDK's `SDKControlInterruptRequest` type
/// and is distinct from closing or aborting the subprocess.
///
/// # Example
///
/// ```
/// use claude_codes::SDKControlInterruptRequest;
///
/// let interrupt = SDKControlInterruptRequest::new();
/// let json = serde_json::to_string(&interrupt).unwrap();
/// assert_eq!(json, r#"{"subtype":"interrupt"}"#);
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SDKControlInterruptRequest {
subtype: SDKControlInterruptSubtype,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
enum SDKControlInterruptSubtype {
#[serde(rename = "interrupt")]
Interrupt,
}
impl SDKControlInterruptRequest {
/// Create a new interrupt request.
pub fn new() -> Self {
SDKControlInterruptRequest {
subtype: SDKControlInterruptSubtype::Interrupt,
}
}
}
impl Default for SDKControlInterruptRequest {
fn default() -> Self {
Self::new()
}
}
/// Wrapper for outgoing control requests (includes type tag)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlRequestMessage {
#[serde(rename = "type")]
pub message_type: String,
pub request_id: String,
pub request: ControlRequestPayload,
}
impl ControlRequestMessage {
/// Create an initialization request to send to CLI
pub fn initialize(request_id: impl Into<String>) -> Self {
ControlRequestMessage {
message_type: "control_request".to_string(),
request_id: request_id.into(),
request: ControlRequestPayload::Initialize(InitializeRequest { hooks: None }),
}
}
/// Create an initialization request with hooks configuration
pub fn initialize_with_hooks(request_id: impl Into<String>, hooks: Value) -> Self {
ControlRequestMessage {
message_type: "control_request".to_string(),
request_id: request_id.into(),
request: ControlRequestPayload::Initialize(InitializeRequest { hooks: Some(hooks) }),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::io::ClaudeOutput;
#[test]
fn test_deserialize_control_request_can_use_tool() {
let json = r#"{
"type": "control_request",
"request_id": "perm-abc123",
"request": {
"subtype": "can_use_tool",
"tool_name": "Write",
"input": {
"file_path": "/home/user/hello.py",
"content": "print('hello')"
},
"permission_suggestions": [],
"blocked_path": null
}
}"#;
let output: ClaudeOutput = serde_json::from_str(json).unwrap();
assert!(output.is_control_request());
if let ClaudeOutput::ControlRequest(req) = output {
assert_eq!(req.request_id, "perm-abc123");
if let ControlRequestPayload::CanUseTool(perm_req) = req.request {
assert_eq!(perm_req.tool_name, "Write");
assert_eq!(
perm_req.input.get("file_path").unwrap().as_str().unwrap(),
"/home/user/hello.py"
);
} else {
panic!("Expected CanUseTool payload");
}
} else {
panic!("Expected ControlRequest");
}
}
#[test]
fn test_deserialize_control_request_edit_tool_real() {
// Real production message from Claude CLI
let json = r#"{"type":"control_request","request_id":"f3cf357c-17d6-4eca-b498-dd17c7ac43dd","request":{"subtype":"can_use_tool","tool_name":"Edit","input":{"file_path":"/home/meawoppl/repos/cc-proxy/proxy/src/ui.rs","old_string":"/// Print hint to re-authenticate\npub fn print_reauth_hint() {\n println!(\n \" {} Run: {} to re-authenticate\",\n \"→\".bright_blue(),\n \"claude-portal logout && claude-portal login\".bright_cyan()\n );\n}","new_string":"/// Print hint to re-authenticate\npub fn print_reauth_hint() {\n println!(\n \" {} Run: {} to re-authenticate\",\n \"→\".bright_blue(),\n \"claude-portal --reauth\".bright_cyan()\n );\n}","replace_all":false},"permission_suggestions":[{"type":"setMode","mode":"acceptEdits","destination":"session"}],"tool_use_id":"toolu_015BDGtNiqNrRSJSDrWXNckW"}}"#;
let output: ClaudeOutput = serde_json::from_str(json).unwrap();
assert!(output.is_control_request());
assert_eq!(output.message_type(), "control_request");
if let ClaudeOutput::ControlRequest(req) = output {
assert_eq!(req.request_id, "f3cf357c-17d6-4eca-b498-dd17c7ac43dd");
if let ControlRequestPayload::CanUseTool(perm_req) = req.request {
assert_eq!(perm_req.tool_name, "Edit");
assert_eq!(
perm_req.input.get("file_path").unwrap().as_str().unwrap(),
"/home/meawoppl/repos/cc-proxy/proxy/src/ui.rs"
);
assert!(perm_req.input.get("old_string").is_some());
assert!(perm_req.input.get("new_string").is_some());
assert!(!perm_req
.input
.get("replace_all")
.unwrap()
.as_bool()
.unwrap());
} else {
panic!("Expected CanUseTool payload");
}
} else {
panic!("Expected ControlRequest");
}
}
#[test]
fn test_tool_permission_request_allow() {
let req = ToolPermissionRequest {
tool_name: "Read".to_string(),
input: serde_json::json!({"file_path": "/tmp/test.txt"}),
permission_suggestions: vec![],
blocked_path: None,
decision_reason: None,
tool_use_id: None,
};
let response = req.allow("req-123");
let message: ControlResponseMessage = response.into();
let json = serde_json::to_string(&message).unwrap();
assert!(json.contains("\"type\":\"control_response\""));
assert!(json.contains("\"subtype\":\"success\""));
assert!(json.contains("\"request_id\":\"req-123\""));
assert!(json.contains("\"behavior\":\"allow\""));
assert!(json.contains("\"updatedInput\""));
}
#[test]
fn test_tool_permission_request_allow_with_modified_input() {
let req = ToolPermissionRequest {
tool_name: "Write".to_string(),
input: serde_json::json!({"file_path": "/etc/passwd", "content": "test"}),
permission_suggestions: vec![],
blocked_path: None,
decision_reason: None,
tool_use_id: None,
};
let modified_input = serde_json::json!({
"file_path": "/tmp/safe/passwd",
"content": "test"
});
let response = req.allow_with(modified_input, "req-456");
let message: ControlResponseMessage = response.into();
let json = serde_json::to_string(&message).unwrap();
assert!(json.contains("/tmp/safe/passwd"));
assert!(!json.contains("/etc/passwd"));
}
#[test]
fn test_tool_permission_request_deny() {
let req = ToolPermissionRequest {
tool_name: "Bash".to_string(),
input: serde_json::json!({"command": "sudo rm -rf /"}),
permission_suggestions: vec![],
blocked_path: None,
decision_reason: None,
tool_use_id: None,
};
let response = req.deny("Dangerous command blocked", "req-789");
let message: ControlResponseMessage = response.into();
let json = serde_json::to_string(&message).unwrap();
assert!(json.contains("\"behavior\":\"deny\""));
assert!(json.contains("Dangerous command blocked"));
assert!(!json.contains("\"interrupt\":true"));
}
#[test]
fn test_tool_permission_request_deny_and_stop() {
let req = ToolPermissionRequest {
tool_name: "Bash".to_string(),
input: serde_json::json!({"command": "rm -rf /"}),
permission_suggestions: vec![],
blocked_path: None,
decision_reason: None,
tool_use_id: None,
};
let response = req.deny_and_stop("Security violation", "req-000");
let message: ControlResponseMessage = response.into();
let json = serde_json::to_string(&message).unwrap();
assert!(json.contains("\"behavior\":\"deny\""));
assert!(json.contains("\"interrupt\":true"));
}
#[test]
fn test_permission_result_serialization() {
// Test allow
let allow = PermissionResult::allow(serde_json::json!({"test": "value"}));
let json = serde_json::to_string(&allow).unwrap();
assert!(json.contains("\"behavior\":\"allow\""));
assert!(json.contains("\"updatedInput\""));
// Test deny
let deny = PermissionResult::deny("Not allowed");
let json = serde_json::to_string(&deny).unwrap();
assert!(json.contains("\"behavior\":\"deny\""));
assert!(json.contains("\"message\":\"Not allowed\""));
assert!(!json.contains("\"interrupt\""));
// Test deny with interrupt
let deny_stop = PermissionResult::deny_and_interrupt("Stop!");
let json = serde_json::to_string(&deny_stop).unwrap();
assert!(json.contains("\"interrupt\":true"));
}
#[test]
fn test_control_request_message_initialize() {
let init = ControlRequestMessage::initialize("init-1");
let json = serde_json::to_string(&init).unwrap();
assert!(json.contains("\"type\":\"control_request\""));
assert!(json.contains("\"request_id\":\"init-1\""));
assert!(json.contains("\"subtype\":\"initialize\""));
}
#[test]
fn test_control_response_error() {
let response = ControlResponse::error("req-err", "Something went wrong");
let message: ControlResponseMessage = response.into();
let json = serde_json::to_string(&message).unwrap();
assert!(json.contains("\"subtype\":\"error\""));
assert!(json.contains("\"error\":\"Something went wrong\""));
}
#[test]
fn test_roundtrip_control_request() {
let original_json = r#"{
"type": "control_request",
"request_id": "test-123",
"request": {
"subtype": "can_use_tool",
"tool_name": "Bash",
"input": {"command": "ls -la"},
"permission_suggestions": []
}
}"#;
let output: ClaudeOutput = serde_json::from_str(original_json).unwrap();
let reserialized = serde_json::to_string(&output).unwrap();
assert!(reserialized.contains("control_request"));
assert!(reserialized.contains("test-123"));
assert!(reserialized.contains("Bash"));
}
#[test]
fn test_permission_suggestions_parsing() {
let json = r#"{
"type": "control_request",
"request_id": "perm-456",
"request": {
"subtype": "can_use_tool",
"tool_name": "Bash",
"input": {"command": "npm test"},
"permission_suggestions": [
{"type": "setMode", "mode": "acceptEdits", "destination": "session"},
{"type": "setMode", "mode": "bypassPermissions", "destination": "project"}
]
}
}"#;
let output: ClaudeOutput = serde_json::from_str(json).unwrap();
if let ClaudeOutput::ControlRequest(req) = output {
if let ControlRequestPayload::CanUseTool(perm_req) = req.request {
assert_eq!(perm_req.permission_suggestions.len(), 2);
assert_eq!(
perm_req.permission_suggestions[0].suggestion_type,
PermissionType::SetMode
);
assert_eq!(
perm_req.permission_suggestions[0].mode,
Some(PermissionModeName::AcceptEdits)
);
assert_eq!(
perm_req.permission_suggestions[0].destination,
PermissionDestination::Session
);
assert_eq!(
perm_req.permission_suggestions[1].suggestion_type,
PermissionType::SetMode
);
assert_eq!(
perm_req.permission_suggestions[1].mode,
Some(PermissionModeName::BypassPermissions)
);
assert_eq!(
perm_req.permission_suggestions[1].destination,
PermissionDestination::Project
);
} else {
panic!("Expected CanUseTool payload");
}
} else {
panic!("Expected ControlRequest");
}
}
#[test]
fn test_permission_suggestion_set_mode_roundtrip() {
let suggestion = PermissionSuggestion {
suggestion_type: PermissionType::SetMode,
destination: PermissionDestination::Session,
mode: Some(PermissionModeName::AcceptEdits),
behavior: None,
rules: None,
};
let json = serde_json::to_string(&suggestion).unwrap();
assert!(json.contains("\"type\":\"setMode\""));
assert!(json.contains("\"mode\":\"acceptEdits\""));
assert!(json.contains("\"destination\":\"session\""));
assert!(!json.contains("\"behavior\""));
assert!(!json.contains("\"rules\""));
let parsed: PermissionSuggestion = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, suggestion);
}
#[test]
fn test_permission_suggestion_add_rules_roundtrip() {
let suggestion = PermissionSuggestion {
suggestion_type: PermissionType::AddRules,
destination: PermissionDestination::Session,
mode: None,
behavior: Some(PermissionBehavior::Allow),
rules: Some(vec![serde_json::json!({
"toolName": "Read",
"ruleContent": "//tmp/**"
})]),
};
let json = serde_json::to_string(&suggestion).unwrap();
assert!(json.contains("\"type\":\"addRules\""));
assert!(json.contains("\"behavior\":\"allow\""));
assert!(json.contains("\"destination\":\"session\""));
assert!(json.contains("\"rules\""));
assert!(json.contains("\"toolName\":\"Read\""));
assert!(!json.contains("\"mode\""));
let parsed: PermissionSuggestion = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, suggestion);
}
#[test]
fn test_permission_suggestion_add_rules_from_real_json() {
let json = r#"{"type":"addRules","rules":[{"toolName":"Read","ruleContent":"//tmp/**"}],"behavior":"allow","destination":"session"}"#;
let parsed: PermissionSuggestion = serde_json::from_str(json).unwrap();
assert_eq!(parsed.suggestion_type, PermissionType::AddRules);
assert_eq!(parsed.destination, PermissionDestination::Session);
assert_eq!(parsed.behavior, Some(PermissionBehavior::Allow));
assert!(parsed.rules.is_some());
assert!(parsed.mode.is_none());
}
#[test]
fn test_permission_allow_tool() {
let perm = Permission::allow_tool("Bash", "npm test");
assert_eq!(perm.permission_type, PermissionType::AddRules);
assert_eq!(perm.destination, PermissionDestination::Session);
assert_eq!(perm.behavior, Some(PermissionBehavior::Allow));
assert!(perm.mode.is_none());
let rules = perm.rules.unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].tool_name, "Bash");
assert_eq!(rules[0].rule_content, "npm test");
}
#[test]
fn test_permission_allow_tool_with_destination() {
let perm = Permission::allow_tool_with_destination(
"Read",
"/tmp/**",
PermissionDestination::Project,
);
assert_eq!(perm.permission_type, PermissionType::AddRules);
assert_eq!(perm.destination, PermissionDestination::Project);
assert_eq!(perm.behavior, Some(PermissionBehavior::Allow));
let rules = perm.rules.unwrap();
assert_eq!(rules[0].tool_name, "Read");
assert_eq!(rules[0].rule_content, "/tmp/**");
}
#[test]
fn test_permission_set_mode() {
let perm = Permission::set_mode(
PermissionModeName::AcceptEdits,
PermissionDestination::Session,
);
assert_eq!(perm.permission_type, PermissionType::SetMode);
assert_eq!(perm.destination, PermissionDestination::Session);
assert_eq!(perm.mode, Some(PermissionModeName::AcceptEdits));
assert!(perm.behavior.is_none());
assert!(perm.rules.is_none());
}
#[test]
fn test_permission_serialization() {
let perm = Permission::allow_tool("Bash", "npm test");
let json = serde_json::to_string(&perm).unwrap();
assert!(json.contains("\"type\":\"addRules\""));
assert!(json.contains("\"destination\":\"session\""));
assert!(json.contains("\"behavior\":\"allow\""));
assert!(json.contains("\"toolName\":\"Bash\""));
assert!(json.contains("\"ruleContent\":\"npm test\""));
}
#[test]
fn test_permission_from_suggestion_set_mode() {
let suggestion = PermissionSuggestion {
suggestion_type: PermissionType::SetMode,
destination: PermissionDestination::Session,
mode: Some(PermissionModeName::AcceptEdits),
behavior: None,
rules: None,
};
let perm = Permission::from_suggestion(&suggestion);
assert_eq!(perm.permission_type, PermissionType::SetMode);
assert_eq!(perm.destination, PermissionDestination::Session);
assert_eq!(perm.mode, Some(PermissionModeName::AcceptEdits));
}
#[test]
fn test_permission_from_suggestion_add_rules() {
let suggestion = PermissionSuggestion {
suggestion_type: PermissionType::AddRules,
destination: PermissionDestination::Session,
mode: None,
behavior: Some(PermissionBehavior::Allow),
rules: Some(vec![serde_json::json!({
"toolName": "Read",
"ruleContent": "/tmp/**"
})]),
};
let perm = Permission::from_suggestion(&suggestion);
assert_eq!(perm.permission_type, PermissionType::AddRules);
assert_eq!(perm.behavior, Some(PermissionBehavior::Allow));
let rules = perm.rules.unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].tool_name, "Read");
assert_eq!(rules[0].rule_content, "/tmp/**");
}
#[test]
fn test_permission_result_allow_with_typed_permissions() {
let result = PermissionResult::allow_with_typed_permissions(
serde_json::json!({"command": "npm test"}),
vec![Permission::allow_tool("Bash", "npm test")],
);
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"behavior\":\"allow\""));
assert!(json.contains("\"updatedPermissions\""));
assert!(json.contains("\"toolName\":\"Bash\""));
}
#[test]
fn test_tool_permission_request_allow_and_remember() {
let req = ToolPermissionRequest {
tool_name: "Bash".to_string(),
input: serde_json::json!({"command": "npm test"}),
permission_suggestions: vec![],
blocked_path: None,
decision_reason: None,
tool_use_id: None,
};
let response =
req.allow_and_remember(vec![Permission::allow_tool("Bash", "npm test")], "req-123");
let message: ControlResponseMessage = response.into();
let json = serde_json::to_string(&message).unwrap();
assert!(json.contains("\"type\":\"control_response\""));
assert!(json.contains("\"behavior\":\"allow\""));
assert!(json.contains("\"updatedPermissions\""));
assert!(json.contains("\"toolName\":\"Bash\""));
}
#[test]
fn test_tool_permission_request_allow_and_remember_suggestion() {
let req = ToolPermissionRequest {
tool_name: "Bash".to_string(),
input: serde_json::json!({"command": "npm test"}),
permission_suggestions: vec![PermissionSuggestion {
suggestion_type: PermissionType::SetMode,
destination: PermissionDestination::Session,
mode: Some(PermissionModeName::AcceptEdits),
behavior: None,
rules: None,
}],
blocked_path: None,
decision_reason: None,
tool_use_id: None,
};
let response = req.allow_and_remember_suggestion("req-123");
assert!(response.is_some());
let message: ControlResponseMessage = response.unwrap().into();
let json = serde_json::to_string(&message).unwrap();
assert!(json.contains("\"type\":\"setMode\""));
assert!(json.contains("\"mode\":\"acceptEdits\""));
}
#[test]
fn test_tool_permission_request_allow_and_remember_suggestion_none() {
let req = ToolPermissionRequest {
tool_name: "Bash".to_string(),
input: serde_json::json!({"command": "npm test"}),
permission_suggestions: vec![], // No suggestions
blocked_path: None,
decision_reason: None,
tool_use_id: None,
};
let response = req.allow_and_remember_suggestion("req-123");
assert!(response.is_none());
}
fn ask_user_question_request() -> ToolPermissionRequest {
ToolPermissionRequest {
tool_name: "AskUserQuestion".to_string(),
input: serde_json::json!({
"questions": [
{
"question": "Which color do you prefer?",
"header": "Color",
"options": [
{"label": "Red", "description": "warm"},
{"label": "Blue", "description": "cool"}
],
"multiSelect": false
},
{
"question": "Pick a size",
"header": "Size",
"options": [
{"label": "Small"},
{"label": "Large"}
],
"multiSelect": false
}
]
}),
permission_suggestions: vec![],
blocked_path: None,
decision_reason: None,
tool_use_id: None,
}
}
fn extract_updated_input(resp: &ControlResponse) -> Value {
let ControlResponsePayload::Success { response, .. } = &resp.response else {
panic!("expected Success payload");
};
let inner = response.as_ref().expect("response body present");
inner
.get("updatedInput")
.cloned()
.expect("updatedInput field present")
}
#[test]
fn answer_questions_keys_by_question_text_and_preserves_questions() {
let req = ask_user_question_request();
let mut answers = HashMap::new();
answers.insert(0, "Blue".to_string());
answers.insert(1, "Large".to_string());
let resp = req.answer_questions(&answers, "rid-1").unwrap();
let updated = extract_updated_input(&resp);
// questions array is preserved verbatim
assert_eq!(
updated["questions"], req.input["questions"],
"questions array must round-trip unchanged"
);
// answers keyed by question text (not header, not index)
assert_eq!(
updated["answers"]["Which color do you prefer?"],
Value::String("Blue".into())
);
assert_eq!(
updated["answers"]["Pick a size"],
Value::String("Large".into())
);
// header is NOT used as the key
assert!(updated["answers"].get("Color").is_none());
assert!(updated["answers"].get("Size").is_none());
}
#[test]
fn answer_questions_partial_answers_omits_unanswered() {
let req = ask_user_question_request();
let mut answers = HashMap::new();
answers.insert(1, "Small".to_string());
let resp = req.answer_questions(&answers, "rid-2").unwrap();
let updated = extract_updated_input(&resp);
assert_eq!(
updated["answers"]["Pick a size"],
Value::String("Small".into())
);
assert!(updated["answers"]
.get("Which color do you prefer?")
.is_none());
}
#[test]
fn answer_questions_rejects_wrong_tool() {
let mut req = ask_user_question_request();
req.tool_name = "Bash".to_string();
let mut answers = HashMap::new();
answers.insert(0, "Blue".to_string());
match req.answer_questions(&answers, "rid-3").unwrap_err() {
AskUserQuestionResponseError::WrongTool(name) => assert_eq!(name, "Bash"),
other => panic!("expected WrongTool, got {other:?}"),
}
}
#[test]
fn answer_questions_rejects_unparseable_input() {
let req = ToolPermissionRequest {
tool_name: "AskUserQuestion".to_string(),
input: serde_json::json!({"not_questions": "garbage"}),
permission_suggestions: vec![],
blocked_path: None,
decision_reason: None,
tool_use_id: None,
};
let answers = HashMap::new();
match req.answer_questions(&answers, "rid-4").unwrap_err() {
AskUserQuestionResponseError::ParseInput(_) => {}
other => panic!("expected ParseInput, got {other:?}"),
}
}
#[test]
fn answer_questions_rejects_out_of_range_index() {
let req = ask_user_question_request();
let mut answers = HashMap::new();
answers.insert(7, "ghost".to_string());
match req.answer_questions(&answers, "rid-5").unwrap_err() {
AskUserQuestionResponseError::QuestionIndexOutOfRange { index, total } => {
assert_eq!(index, 7);
assert_eq!(total, 2);
}
other => panic!("expected QuestionIndexOutOfRange, got {other:?}"),
}
}
}