use crate::{EffectKind, EffectRequest, RiskLevel, RunMetadata};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use super::command::CommandRequest;
use super::error::CodingError;
use super::git::GitOperation;
use super::workspace::{
FileVersion, FileWriteContent, ListFilesQuery, Patch, WorkspacePath, WriteFileRequest,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReadFilePayload {
pub path: WorkspacePath,
pub max_bytes: Option<usize>,
}
impl ReadFilePayload {
pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
payload_into_effect(
EffectKind::ReadFile,
format!("Read file {}", self.path.display()),
RiskLevel::Low,
self,
)
}
pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
payload_from_effect(request, &EffectKind::ReadFile)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListFilesPayload {
pub path: WorkspacePath,
pub recursive: bool,
pub max_entries: Option<usize>,
pub include_hidden: bool,
pub respect_gitignore: bool,
}
impl ListFilesPayload {
pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
payload_into_effect(
EffectKind::Search,
format!("List files under {}", self.path.display()),
RiskLevel::Low,
self,
)
}
pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
payload_from_effect(request, &EffectKind::Search)
}
pub(crate) fn into_query(self) -> ListFilesQuery {
ListFilesQuery {
path: self.path,
recursive: self.recursive,
max_entries: self.max_entries,
include_hidden: self.include_hidden,
respect_gitignore: self.respect_gitignore,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WriteFilePayload {
pub path: WorkspacePath,
pub content: FileWriteContent,
pub expected_version: Option<FileVersion>,
pub create: bool,
pub overwrite: bool,
}
impl WriteFilePayload {
pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
payload_into_effect(
EffectKind::WriteFile,
format!("Write file {}", self.path.display()),
RiskLevel::Medium,
self,
)
}
pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
payload_from_effect(request, &EffectKind::WriteFile)
}
pub(crate) fn into_request(self) -> WriteFileRequest {
WriteFileRequest {
path: self.path,
content: self.content,
expected_version: self.expected_version,
create: self.create,
overwrite: self.overwrite,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApplyPatchPayload {
pub patch: Patch,
pub expected_versions: Vec<FileVersion>,
pub dry_run: bool,
}
impl ApplyPatchPayload {
pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
payload_into_effect(
EffectKind::ApplyPatch,
format!("Apply patch to {} file(s)", self.patch.files.len()),
RiskLevel::Medium,
self,
)
}
pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
payload_from_effect(request, &EffectKind::ApplyPatch)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SearchPayload {
pub query: String,
pub paths: Vec<WorkspacePath>,
pub max_matches: Option<usize>,
pub context_lines: usize,
}
impl SearchPayload {
pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
payload_into_effect(
EffectKind::Search,
format!("Search repository for {}", self.query),
RiskLevel::Low,
self,
)
}
pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
payload_from_effect(request, &EffectKind::Search)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandPayload {
pub request: CommandRequest,
}
impl CommandPayload {
pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
let description = format!("Run command {}", self.request.argv.join(" "));
payload_into_effect(
EffectKind::ExecuteCommand,
description,
command_risk(&self.request.argv),
self,
)
}
pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
payload_from_effect(request, &EffectKind::ExecuteCommand)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitPayload {
pub operation: GitOperation,
}
impl GitPayload {
pub fn into_effect(self) -> Result<EffectRequest, CodingError> {
payload_into_effect(EffectKind::Git, "Inspect git state", RiskLevel::Low, self)
}
pub fn from_effect(request: &EffectRequest) -> Result<Self, CodingError> {
payload_from_effect(request, &EffectKind::Git)
}
}
fn payload_into_effect<T>(
kind: EffectKind,
description: impl Into<String>,
risk: RiskLevel,
payload: T,
) -> Result<EffectRequest, CodingError>
where
T: Serialize,
{
let payload = serde_json::to_value(payload).map_err(|error| CodingError::InvalidPayload {
message: format!("failed to encode payload: {error}"),
})?;
Ok(EffectRequest::new(kind, description, payload)
.with_risk(risk)
.with_metadata(RunMetadata::new()))
}
fn payload_from_effect<T>(request: &EffectRequest, expected: &EffectKind) -> Result<T, CodingError>
where
T: DeserializeOwned,
{
if !effect_kind_eq(&request.kind, expected) {
return Err(CodingError::unexpected_kind(format!(
"expected {:?}, got {:?}",
expected, request.kind
)));
}
serde_json::from_value(request.payload.clone()).map_err(|error| CodingError::InvalidPayload {
message: format!("failed to decode payload: {error}"),
})
}
fn effect_kind_eq(left: &EffectKind, right: &EffectKind) -> bool {
match (left, right) {
(EffectKind::ReadFile, EffectKind::ReadFile)
| (EffectKind::WriteFile, EffectKind::WriteFile)
| (EffectKind::ApplyPatch, EffectKind::ApplyPatch)
| (EffectKind::Search, EffectKind::Search)
| (EffectKind::ExecuteCommand, EffectKind::ExecuteCommand)
| (EffectKind::Git, EffectKind::Git)
| (EffectKind::Network, EffectKind::Network)
| (EffectKind::Browser, EffectKind::Browser)
| (EffectKind::Mcp, EffectKind::Mcp) => true,
(EffectKind::Custom(left), EffectKind::Custom(right)) => left == right,
_ => false,
}
}
fn command_risk(argv: &[String]) -> RiskLevel {
if argv.is_empty() {
return RiskLevel::Medium;
}
let lowered = argv.join(" ").to_ascii_lowercase();
if argv
.first()
.is_some_and(|arg| arg == "sh" || arg == "bash" || arg == "zsh")
|| lowered.contains("rm -rf")
|| lowered.contains("git reset --hard")
|| lowered.contains("push --force")
|| lowered.contains("sudo ")
{
RiskLevel::High
} else {
RiskLevel::Medium
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coding::WorkspacePath;
#[test]
fn read_payload_round_trips_effect() {
let payload = ReadFilePayload {
path: WorkspacePath::parse("src/lib.rs").unwrap(),
max_bytes: Some(128),
};
let effect = payload.clone().into_effect().unwrap();
assert_eq!(effect.kind, EffectKind::ReadFile);
assert_eq!(ReadFilePayload::from_effect(&effect).unwrap(), payload);
}
#[test]
fn command_payload_marks_shell_high_risk() {
let payload = CommandPayload {
request: CommandRequest::new(["sh", "-c", "echo hi"]),
};
let effect = payload.into_effect().unwrap();
assert_eq!(effect.risk, RiskLevel::High);
}
#[test]
fn command_payload_marks_destructive_patterns_high_risk() {
let cases = [
vec!["sudo", "whoami"],
vec!["git", "push", "--force"],
vec!["bash", "-lc", "echo hi"],
vec!["sh", "-c", "rm -rf target"],
];
for argv in cases {
let payload = CommandPayload {
request: CommandRequest::new(argv),
};
let effect = payload.into_effect().unwrap();
assert_eq!(effect.risk, RiskLevel::High);
}
}
#[test]
fn command_payload_keeps_plain_commands_medium_risk() {
let payload = CommandPayload {
request: CommandRequest::new(["cargo", "test", "--workspace"]),
};
let effect = payload.into_effect().unwrap();
assert_eq!(effect.risk, RiskLevel::Medium);
}
}