1use crate::{EffectKind, EffectRequest, RiskLevel, RunMetadata};
2use serde::de::DeserializeOwned;
3use serde::{Deserialize, Serialize};
4
5use super::command::CommandRequest;
6use super::error::CodingError;
7use super::git::GitOperation;
8use super::workspace::{
9 FileVersion, FileWriteContent, ListFilesQuery, Patch, WorkspacePath, WriteFileRequest,
10};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ReadFilePayload {
15 pub path: WorkspacePath,
17 pub max_bytes: Option<usize>,
19}
20
21impl ReadFilePayload {
22 pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
28 payload_into_effect(
29 EffectKind::ReadFile,
30 format!("Read file {}", self.path.display()),
31 RiskLevel::Low,
32 self,
33 )
34 }
35
36 pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
42 payload_from_effect(request, &EffectKind::ReadFile)
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ListFilesPayload {
49 pub path: WorkspacePath,
51 pub recursive: bool,
53 pub max_entries: Option<usize>,
55 pub include_hidden: bool,
57 pub respect_gitignore: bool,
59}
60
61impl ListFilesPayload {
62 pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
71 payload_into_effect(
72 EffectKind::Search,
73 format!("List files under {}", self.path.display()),
74 RiskLevel::Low,
75 self,
76 )
77 }
78
79 pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
85 payload_from_effect(request, &EffectKind::Search)
86 }
87
88 pub(crate) fn into_query(self) -> ListFilesQuery {
89 ListFilesQuery {
90 path: self.path,
91 recursive: self.recursive,
92 max_entries: self.max_entries,
93 include_hidden: self.include_hidden,
94 respect_gitignore: self.respect_gitignore,
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct WriteFilePayload {
102 pub path: WorkspacePath,
104 pub content: FileWriteContent,
106 pub expected_version: Option<FileVersion>,
108 pub create: bool,
110 pub overwrite: bool,
112}
113
114impl WriteFilePayload {
115 pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
121 payload_into_effect(
122 EffectKind::WriteFile,
123 format!("Write file {}", self.path.display()),
124 RiskLevel::Medium,
125 self,
126 )
127 }
128
129 pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
135 payload_from_effect(request, &EffectKind::WriteFile)
136 }
137
138 pub(crate) fn into_request(self) -> WriteFileRequest {
139 WriteFileRequest {
140 path: self.path,
141 content: self.content,
142 expected_version: self.expected_version,
143 create: self.create,
144 overwrite: self.overwrite,
145 }
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct ApplyPatchPayload {
152 pub patch: Patch,
154 pub expected_versions: Vec<FileVersion>,
156 pub dry_run: bool,
158}
159
160impl ApplyPatchPayload {
161 pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
167 payload_into_effect(
168 EffectKind::ApplyPatch,
169 format!("Apply patch to {} file(s)", self.patch.files.len()),
170 RiskLevel::Medium,
171 self,
172 )
173 }
174
175 pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
181 payload_from_effect(request, &EffectKind::ApplyPatch)
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct SearchPayload {
188 pub query: String,
190 pub paths: Vec<WorkspacePath>,
192 pub max_matches: Option<usize>,
194 pub context_lines: usize,
196}
197
198impl SearchPayload {
199 pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
205 payload_into_effect(
206 EffectKind::Search,
207 format!("Search repository for {}", self.query),
208 RiskLevel::Low,
209 self,
210 )
211 }
212
213 pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
219 payload_from_effect(request, &EffectKind::Search)
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct CommandPayload {
226 pub request: CommandRequest,
228}
229
230impl CommandPayload {
231 pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
237 let description = format!("Run command {}", self.request.argv.join(" "));
238 payload_into_effect(
239 EffectKind::ExecuteCommand,
240 description,
241 command_risk(&self.request.argv),
242 self,
243 )
244 }
245
246 pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
252 payload_from_effect(request, &EffectKind::ExecuteCommand)
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct GitPayload {
259 pub operation: GitOperation,
261}
262
263impl GitPayload {
264 pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
270 payload_into_effect(EffectKind::Git, "Inspect git state", RiskLevel::Low, self)
271 }
272
273 pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
279 payload_from_effect(request, &EffectKind::Git)
280 }
281}
282
283fn payload_into_effect<T>(
284 kind: EffectKind,
285 description: impl Into<String>,
286 risk: RiskLevel,
287 payload: T,
288) -> Result<EffectRequest, CodingError>
289where
290 T: Serialize,
291{
292 let payload = serde_json::to_value(payload).map_err(|error| CodingError::InvalidPayload {
293 message: format!("failed to encode payload: {error}"),
294 })?;
295 Ok(EffectRequest::new(kind, description, payload)
296 .with_risk(risk)
297 .with_metadata(RunMetadata::new()))
298}
299
300fn payload_from_effect<T>(request: &EffectRequest, expected: &EffectKind) -> Result<T, CodingError>
301where
302 T: DeserializeOwned,
303{
304 if !effect_kind_eq(&request.kind, expected) {
305 return Err(CodingError::unexpected_kind(format!(
306 "expected {:?}, got {:?}",
307 expected, request.kind
308 )));
309 }
310 serde_json::from_value(request.payload.clone()).map_err(|error| CodingError::InvalidPayload {
311 message: format!("failed to decode payload: {error}"),
312 })
313}
314
315fn effect_kind_eq(left: &EffectKind, right: &EffectKind) -> bool {
316 match (left, right) {
317 (EffectKind::ReadFile, EffectKind::ReadFile)
318 | (EffectKind::WriteFile, EffectKind::WriteFile)
319 | (EffectKind::ApplyPatch, EffectKind::ApplyPatch)
320 | (EffectKind::Search, EffectKind::Search)
321 | (EffectKind::ExecuteCommand, EffectKind::ExecuteCommand)
322 | (EffectKind::Git, EffectKind::Git)
323 | (EffectKind::Network, EffectKind::Network)
324 | (EffectKind::Browser, EffectKind::Browser)
325 | (EffectKind::Mcp, EffectKind::Mcp) => true,
326 (EffectKind::Custom(left), EffectKind::Custom(right)) => left == right,
327 _ => false,
328 }
329}
330
331fn command_risk(argv: &[String]) -> RiskLevel {
332 if argv.is_empty() {
333 return RiskLevel::Medium;
334 }
335 let lowered = argv.join(" ").to_ascii_lowercase();
336 if argv
337 .first()
338 .is_some_and(|arg| arg == "sh" || arg == "bash" || arg == "zsh")
339 || lowered.contains("rm -rf")
340 || lowered.contains("git reset --hard")
341 || lowered.contains("push --force")
342 || lowered.contains("sudo ")
343 {
344 RiskLevel::High
345 } else {
346 RiskLevel::Medium
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::coding::WorkspacePath;
354
355 #[test]
356 fn read_payload_round_trips_effect() {
357 let payload = ReadFilePayload {
358 path: WorkspacePath::parse("src/lib.rs").unwrap(),
359 max_bytes: Some(128),
360 };
361 let effect = payload.clone().into_effect().unwrap();
362 assert_eq!(effect.kind, EffectKind::ReadFile);
363 assert_eq!(ReadFilePayload::from_effect(&effect).unwrap(), payload);
364 }
365
366 #[test]
367 fn command_payload_marks_shell_high_risk() {
368 let payload = CommandPayload {
369 request: CommandRequest::new(["sh", "-c", "echo hi"]),
370 };
371 let effect = payload.into_effect().unwrap();
372 assert_eq!(effect.risk, RiskLevel::High);
373 }
374
375 #[test]
376 fn command_payload_marks_destructive_patterns_high_risk() {
377 let cases = [
378 vec!["sudo", "whoami"],
379 vec!["git", "push", "--force"],
380 vec!["bash", "-lc", "echo hi"],
381 vec!["sh", "-c", "rm -rf target"],
382 ];
383 for argv in cases {
384 let payload = CommandPayload {
385 request: CommandRequest::new(argv),
386 };
387 let effect = payload.into_effect().unwrap();
388 assert_eq!(effect.risk, RiskLevel::High);
389 }
390 }
391
392 #[test]
393 fn command_payload_keeps_plain_commands_medium_risk() {
394 let payload = CommandPayload {
395 request: CommandRequest::new(["cargo", "test", "--workspace"]),
396 };
397 let effect = payload.into_effect().unwrap();
398 assert_eq!(effect.risk, RiskLevel::Medium);
399 }
400}