Skip to main content

oparry_wrapper/
protocol.rs

1//! Protocol for Claude Code wrapper communication
2//!
3//! This module defines the JSON-based protocol used between
4//! Claude Code and Parry for intercepting and validating file writes.
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9/// Protocol version
10pub const PROTOCOL_VERSION: &str = "0.2.0";
11
12/// Request types from Claude Code
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(tag = "type", rename_all = "snake_case")]
15pub enum ClaudeRequest {
16    /// Request to write a file
17    WriteFile(WriteFileRequest),
18    /// Request to edit a file
19    EditFile(EditFileRequest),
20    /// Ping/heartbeat
21    Ping,
22}
23
24/// Request to write a complete file
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct WriteFileRequest {
27    /// Unique request ID
28    pub id: String,
29    /// File path to write
30    pub path: PathBuf,
31    /// File content
32    pub content: String,
33    /// File encoding (default: utf-8)
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub encoding: Option<String>,
36    /// Create directories if needed
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub create_dirs: Option<bool>,
39}
40
41/// Request to edit a file (partial replacement)
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct EditFileRequest {
44    /// Unique request ID
45    pub id: String,
46    /// File path to edit
47    pub path: PathBuf,
48    /// Old string to replace
49    pub old_string: String,
50    /// New string to replace with
51    pub new_string: String,
52    /// Replace all occurrences
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub replace_all: Option<bool>,
55}
56
57/// Response types to Claude Code
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(tag = "type", rename_all = "snake_case")]
60pub enum ClaudeResponse {
61    /// Validation passed - proceed with write
62    Approved(ApprovedResponse),
63    /// Validation failed - block or warn
64    Rejected(RejectedResponse),
65    /// Validation passed with warnings
66    Warning(WarningResponse),
67    /// Pong response
68    Pong,
69    /// Error in protocol handling
70    ProtocolError(ProtocolErrorResponse),
71}
72
73/// Response indicating validation passed
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ApprovedResponse {
76    /// Original request ID
77    pub request_id: String,
78    /// Optional modified content (if Parry made fixes)
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub modified_content: Option<String>,
81}
82
83/// Response indicating validation failed
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct RejectedResponse {
86    /// Original request ID
87    pub request_id: String,
88    /// Error message
89    pub message: String,
90    /// List of validation issues
91    pub issues: Vec<IssueDetail>,
92    /// Whether Claude should attempt auto-fix
93    pub can_autofix: bool,
94}
95
96/// Response with warnings but allowed to proceed
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct WarningResponse {
99    /// Original request ID
100    pub request_id: String,
101    /// Warning message
102    pub message: String,
103    /// List of warnings
104    pub warnings: Vec<IssueDetail>,
105    /// Optional modified content
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub modified_content: Option<String>,
108}
109
110/// Protocol error response
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct ProtocolErrorResponse {
113    /// Error message
114    pub message: String,
115    /// Error code for programmatic handling
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub code: Option<String>,
118}
119
120/// Detailed issue information
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct IssueDetail {
123    /// Issue code (e.g., "tailwind-invalid-class")
124    pub code: String,
125    /// Severity level
126    pub level: IssueSeverity,
127    /// Human-readable message
128    pub message: String,
129    /// Line number (0-indexed)
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub line: Option<usize>,
132    /// Column number (0-indexed)
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub column: Option<usize>,
135    /// Suggested fix
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub suggestion: Option<String>,
138    /// Context snippet
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub context: Option<String>,
141}
142
143/// Issue severity level
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "lowercase")]
146pub enum IssueSeverity {
147    /// Informational note
148    Note,
149    /// Warning (doesn't block)
150    Warning,
151    /// Error (blocks in strict mode)
152    Error,
153}
154
155impl From<oparry_core::IssueLevel> for IssueSeverity {
156    fn from(level: oparry_core::IssueLevel) -> Self {
157        match level {
158            oparry_core::IssueLevel::Note => IssueSeverity::Note,
159            oparry_core::IssueLevel::Warning => IssueSeverity::Warning,
160            oparry_core::IssueLevel::Error => IssueSeverity::Error,
161        }
162    }
163}
164
165impl From<oparry_core::Issue> for IssueDetail {
166    fn from(issue: oparry_core::Issue) -> Self {
167        Self {
168            code: issue.code,
169            level: issue.level.into(),
170            message: issue.message,
171            line: issue.line,
172            column: issue.column,
173            suggestion: issue.suggestion,
174            context: issue.context,
175        }
176    }
177}
178
179impl ClaudeRequest {
180    /// Parse a JSON string into a request
181    pub fn from_json(json: &str) -> crate::Result<Self> {
182        serde_json::from_str(json).map_err(|e| {
183            oparry_core::Error::Wrapper(format!("Failed to parse request JSON: {}", e))
184        })
185    }
186
187    /// Convert request to JSON
188    pub fn to_json(&self) -> crate::Result<String> {
189        serde_json::to_string(self).map_err(|e| {
190            oparry_core::Error::Wrapper(format!("Failed to serialize request: {}", e))
191        })
192    }
193
194    /// Get the request ID for tracking
195    pub fn id(&self) -> Option<&str> {
196        match self {
197            ClaudeRequest::WriteFile(w) => Some(&w.id),
198            ClaudeRequest::EditFile(e) => Some(&e.id),
199            ClaudeRequest::Ping => None,
200        }
201    }
202
203    /// Get the file path for this request
204    pub fn path(&self) -> Option<&PathBuf> {
205        match self {
206            ClaudeRequest::WriteFile(w) => Some(&w.path),
207            ClaudeRequest::EditFile(e) => Some(&e.path),
208            ClaudeRequest::Ping => None,
209        }
210    }
211}
212
213impl ClaudeResponse {
214    /// Convert response to JSON
215    pub fn to_json(&self) -> crate::Result<String> {
216        serde_json::to_string(self).map_err(|e| {
217            oparry_core::Error::Wrapper(format!("Failed to serialize response: {}", e))
218        })
219    }
220
221    /// Create an approved response
222    pub fn approved(request_id: impl Into<String>) -> Self {
223        ClaudeResponse::Approved(ApprovedResponse {
224            request_id: request_id.into(),
225            modified_content: None,
226        })
227    }
228
229    /// Create an approved response with modified content
230    pub fn approved_with_fix(request_id: impl Into<String>, content: impl Into<String>) -> Self {
231        ClaudeResponse::Approved(ApprovedResponse {
232            request_id: request_id.into(),
233            modified_content: Some(content.into()),
234        })
235    }
236
237    /// Create a rejected response
238    pub fn rejected(
239        request_id: impl Into<String>,
240        message: impl Into<String>,
241        issues: Vec<IssueDetail>,
242    ) -> Self {
243        ClaudeResponse::Rejected(RejectedResponse {
244            request_id: request_id.into(),
245            message: message.into(),
246            issues,
247            can_autofix: true, // By default, allow Claude to attempt fixes
248        })
249    }
250
251    /// Create a warning response
252    pub fn warning(
253        request_id: impl Into<String>,
254        message: impl Into<String>,
255        warnings: Vec<IssueDetail>,
256    ) -> Self {
257        ClaudeResponse::Warning(WarningResponse {
258            request_id: request_id.into(),
259            message: message.into(),
260            warnings,
261            modified_content: None,
262        })
263    }
264
265    /// Create a protocol error response
266    pub fn protocol_error(message: impl Into<String>) -> Self {
267        ClaudeResponse::ProtocolError(ProtocolErrorResponse {
268            message: message.into(),
269            code: None,
270        })
271    }
272
273    /// Check if this response allows the write to proceed
274    pub fn is_allowed(&self) -> bool {
275        matches!(self, ClaudeResponse::Approved(_) | ClaudeResponse::Warning(_))
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_write_file_serialization() {
285        let request = ClaudeRequest::WriteFile(WriteFileRequest {
286            id: "test-123".to_string(),
287            path: PathBuf::from("test.ts"),
288            content: "console.log('hello');".to_string(),
289            encoding: Some("utf-8".to_string()),
290            create_dirs: Some(true),
291        });
292
293        let json = request.to_json().unwrap();
294        let parsed = ClaudeRequest::from_json(&json).unwrap();
295
296        assert_eq!(parsed.id(), Some("test-123"));
297        assert_eq!(parsed.path(), Some(&PathBuf::from("test.ts")));
298    }
299
300    #[test]
301    fn test_response_creation() {
302        let response = ClaudeResponse::approved("test-123");
303        assert!(response.is_allowed());
304
305        let response = ClaudeResponse::rejected(
306            "test-456",
307            "Validation failed",
308            vec![],
309        );
310        assert!(!response.is_allowed());
311    }
312
313    #[test]
314    fn test_issue_conversion() {
315        let core_issue = oparry_core::Issue::error("test-error", "Test error message")
316            .with_line(10)
317            .with_column(5)
318            .with_suggestion("Fix it");
319
320        let detail: IssueDetail = core_issue.into();
321        assert_eq!(detail.code, "test-error");
322        assert_eq!(detail.level, IssueSeverity::Error);
323        assert_eq!(detail.line, Some(10));
324        assert_eq!(detail.suggestion, Some("Fix it".to_string()));
325    }
326}