Skip to main content

molo_coding/coding/
payload.rs

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/// Typed payload for reading a workspace file.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ReadFilePayload {
15    /// Root-relative file path.
16    pub path: WorkspacePath,
17    /// Maximum bytes returned by the read.
18    pub max_bytes: Option<usize>,
19}
20
21impl ReadFilePayload {
22    /// Converts this payload into an [`EffectRequest`].
23    ///
24    /// # Errors
25    ///
26    /// Returns [`CodingError::InvalidPayload`] if JSON serialization fails.
27    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    /// Decodes this payload from a matching [`EffectRequest`].
37    ///
38    /// # Errors
39    ///
40    /// Returns [`CodingError`] if the kind or payload is invalid.
41    pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
42        payload_from_effect(request, &EffectKind::ReadFile)
43    }
44}
45
46/// Typed payload for listing workspace files.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ListFilesPayload {
49    /// Directory or file path to list.
50    pub path: WorkspacePath,
51    /// Whether listing should recurse.
52    pub recursive: bool,
53    /// Maximum entries returned.
54    pub max_entries: Option<usize>,
55    /// Whether hidden files are included.
56    pub include_hidden: bool,
57    /// Whether simple `.gitignore` rules are respected.
58    pub respect_gitignore: bool,
59}
60
61impl ListFilesPayload {
62    /// Converts this payload into a search-kind [`EffectRequest`].
63    ///
64    /// Listing uses `EffectKind::Search` because file discovery and content
65    /// search share the same read-only governance path.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`CodingError::InvalidPayload`] if JSON serialization fails.
70    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    /// Decodes this payload from a matching [`EffectRequest`].
80    ///
81    /// # Errors
82    ///
83    /// Returns [`CodingError`] if the kind or payload is invalid.
84    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/// Typed payload for writing a workspace file.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct WriteFilePayload {
102    /// Root-relative file path.
103    pub path: WorkspacePath,
104    /// Content to write.
105    pub content: FileWriteContent,
106    /// Expected existing version.
107    pub expected_version: Option<FileVersion>,
108    /// Whether a missing file may be created.
109    pub create: bool,
110    /// Whether an existing file may be overwritten.
111    pub overwrite: bool,
112}
113
114impl WriteFilePayload {
115    /// Converts this payload into an [`EffectRequest`].
116    ///
117    /// # Errors
118    ///
119    /// Returns [`CodingError::InvalidPayload`] if JSON serialization fails.
120    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    /// Decodes this payload from a matching [`EffectRequest`].
130    ///
131    /// # Errors
132    ///
133    /// Returns [`CodingError`] if the kind or payload is invalid.
134    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/// Typed payload for applying a structured patch.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct ApplyPatchPayload {
152    /// Patch to apply.
153    pub patch: Patch,
154    /// Expected versions for files referenced by the patch.
155    pub expected_versions: Vec<FileVersion>,
156    /// Validate without writing.
157    pub dry_run: bool,
158}
159
160impl ApplyPatchPayload {
161    /// Converts this payload into an [`EffectRequest`].
162    ///
163    /// # Errors
164    ///
165    /// Returns [`CodingError::InvalidPayload`] if JSON serialization fails.
166    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    /// Decodes this payload from a matching [`EffectRequest`].
176    ///
177    /// # Errors
178    ///
179    /// Returns [`CodingError`] if the kind or payload is invalid.
180    pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
181        payload_from_effect(request, &EffectKind::ApplyPatch)
182    }
183}
184
185/// Typed payload for repository search.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct SearchPayload {
188    /// Query string or regex pattern.
189    pub query: String,
190    /// Paths to search. Empty means the workspace root.
191    pub paths: Vec<WorkspacePath>,
192    /// Maximum matches returned.
193    pub max_matches: Option<usize>,
194    /// Context lines around matches.
195    pub context_lines: usize,
196}
197
198impl SearchPayload {
199    /// Converts this payload into an [`EffectRequest`].
200    ///
201    /// # Errors
202    ///
203    /// Returns [`CodingError::InvalidPayload`] if JSON serialization fails.
204    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    /// Decodes this payload from a matching [`EffectRequest`].
214    ///
215    /// # Errors
216    ///
217    /// Returns [`CodingError`] if the kind or payload is invalid.
218    pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
219        payload_from_effect(request, &EffectKind::Search)
220    }
221}
222
223/// Typed payload for command execution.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct CommandPayload {
226    /// Command request.
227    pub request: CommandRequest,
228}
229
230impl CommandPayload {
231    /// Converts this payload into an [`EffectRequest`].
232    ///
233    /// # Errors
234    ///
235    /// Returns [`CodingError::InvalidPayload`] if JSON serialization fails.
236    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    /// Decodes this payload from a matching [`EffectRequest`].
247    ///
248    /// # Errors
249    ///
250    /// Returns [`CodingError`] if the kind or payload is invalid.
251    pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
252        payload_from_effect(request, &EffectKind::ExecuteCommand)
253    }
254}
255
256/// Typed payload for read-only git inspection.
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct GitPayload {
259    /// Git operation.
260    pub operation: GitOperation,
261}
262
263impl GitPayload {
264    /// Converts this payload into an [`EffectRequest`].
265    ///
266    /// # Errors
267    ///
268    /// Returns [`CodingError::InvalidPayload`] if JSON serialization fails.
269    pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
270        payload_into_effect(EffectKind::Git, "Inspect git state", RiskLevel::Low, self)
271    }
272
273    /// Decodes this payload from a matching [`EffectRequest`].
274    ///
275    /// # Errors
276    ///
277    /// Returns [`CodingError`] if the kind or payload is invalid.
278    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}