use crate::subagents::{SubagentTask, profiles::validate_subagent_identity_id};
use serde::Deserialize;
use std::path::PathBuf;
pub(crate) const BASH_STDOUT_MAX_BYTES: usize = 64 * 1024;
pub(crate) const BASH_STDERR_MAX_BYTES: usize = 16 * 1024;
pub(crate) const FILE_READ_MAX_BYTES: u64 = 1024 * 1024;
pub(crate) const FILE_READ_DEFAULT_LINES: usize = 400;
pub(crate) const FILE_READ_MAX_LINES: usize = 2_000;
pub(crate) const FILE_READ_MAX_FILES: usize = 8;
pub(crate) const FILE_WRITE_MAX_BYTES: usize = 1024 * 1024;
pub(crate) const FILE_EDIT_TARGET_MAX_BYTES: u64 = 1024 * 1024;
pub(crate) const FILE_EDIT_TEXT_MAX_BYTES: usize = 256 * 1024;
pub(crate) const FIND_DEFAULT_LIMIT: usize = 50;
pub(crate) const FIND_MAX_LIMIT: usize = 200;
pub(crate) const AST_GREP_STDOUT_MAX_BYTES: usize = 64 * 1024;
pub(crate) const AST_GREP_TIMEOUT_SECS: u64 = 30;
pub(crate) const AST_GREP_DEFAULT_LIMIT: usize = 100;
pub(crate) const AST_GREP_MAX_LIMIT: usize = 500;
pub(crate) const VIEW_IMAGE_RESPONSE_MAX_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ViewImageArgs {
pub(crate) path: String,
pub(crate) prompt: String,
}
impl ViewImageArgs {
pub(crate) fn validate(mut self) -> anyhow::Result<Self> {
self.path = self.path.trim().to_string();
self.prompt = self.prompt.trim().to_string();
if self.path.is_empty() {
anyhow::bail!("path must not be empty");
}
if self.prompt.is_empty() {
anyhow::bail!("prompt must not be empty");
}
if !PathBuf::from(&self.path).is_absolute() {
anyhow::bail!("view_image path must be absolute");
}
Ok(self)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ReadArgs {
pub(crate) path: Option<String>,
pub(crate) paths: Option<Vec<String>>,
pub(crate) offset: Option<usize>,
pub(crate) limit: Option<usize>,
}
impl ReadArgs {
pub(crate) fn validate(self) -> anyhow::Result<Self> {
if self.offset == Some(0) {
anyhow::bail!("offset must be at least 1");
}
if self.limit == Some(0) {
anyhow::bail!("limit must be at least 1");
}
if self.limit.is_some_and(|limit| limit > FILE_READ_MAX_LINES) {
anyhow::bail!("limit must be at most {FILE_READ_MAX_LINES} lines");
}
match (&self.path, &self.paths) {
(Some(_), Some(_)) => anyhow::bail!("provide either path or paths, not both"),
(None, None) => anyhow::bail!("read requires paths"),
(Some(path), None) => {
if path.trim().is_empty() {
anyhow::bail!("path must not be empty");
}
}
(None, Some(paths)) => {
if paths.is_empty() {
anyhow::bail!("paths must contain at least one file");
}
if paths.len() > FILE_READ_MAX_FILES {
anyhow::bail!("paths must contain at most {FILE_READ_MAX_FILES} files");
}
if paths.iter().any(|path| path.trim().is_empty()) {
anyhow::bail!("paths must not contain empty items");
}
}
}
Ok(self)
}
pub(crate) fn is_multi_file(&self) -> bool {
self.paths.is_some()
}
pub(crate) fn requested_paths(&self) -> &[String] {
if let Some(paths) = self.paths.as_ref() {
return paths;
}
self.path.as_slice()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ListFilesArgs {
pub(crate) path: String,
#[serde(default)]
pub(crate) include_directories: bool,
}
impl ListFilesArgs {
pub(crate) fn validate(mut self) -> anyhow::Result<Self> {
self.path = self.path.trim().to_string();
if self.path.is_empty() {
anyhow::bail!("path must not be empty");
}
Ok(self)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct BashArgs {
pub(crate) command: Option<String>,
pub(crate) cmd: Option<String>,
pub(crate) timeout: Option<u64>,
}
impl BashArgs {
pub(crate) fn effective_command(&self) -> Option<&str> {
self.command.as_deref().or(self.cmd.as_deref())
}
pub(crate) fn validate(&self) -> anyhow::Result<()> {
let command = self
.effective_command()
.ok_or_else(|| anyhow::anyhow!("missing command"))?;
if command.trim().is_empty() {
anyhow::bail!("command must not be empty");
}
if let Some(timeout) = self.timeout
&& !(1..=300).contains(&timeout)
{
anyhow::bail!("timeout must be between 1 and 300 seconds");
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct WriteArgs {
pub(crate) path: String,
pub(crate) content: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct HashEditArgs {
pub(crate) input: String,
}
impl HashEditArgs {
pub(crate) fn validate(mut self) -> anyhow::Result<Self> {
self.input = self.input.trim().to_string();
if self.input.is_empty() {
anyhow::bail!("input must not be empty");
}
Ok(self)
}
}
pub(crate) const GREP_MAX_PATTERNS: usize = 8;
pub(crate) const GREP_MAX_PATTERN_CHARS: usize = 512;
pub(crate) const GREP_MAX_PATTERN_BYTES: usize = 1024;
pub(crate) const GREP_MAX_PATTERN_TOTAL_BYTES: usize = 4096;
pub(crate) const GREP_DEFAULT_LIMIT: usize = 50;
pub(crate) const GREP_MAX_LIMIT: usize = 200;
pub(crate) const GREP_MAX_OFFSET: usize = 10_000;
pub(crate) const GREP_MAX_CONTEXT: usize = 20;
pub(crate) const GREP_MAX_FILE_BYTES: usize = 4 * 1024 * 1024;
pub(crate) const GREP_MAX_SCAN_BYTES: usize = 128 * 1024 * 1024;
pub(crate) const GREP_MAX_FILES: usize = 10_000;
pub(crate) const GREP_DEADLINE_SECS: u64 = 10;
pub(crate) const GREP_RANKED_PER_FILE: usize = 5;
pub(crate) const GREP_OUTPUT_MAX_BYTES: usize = 65_536;
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum GrepMode {
Ranked,
Raw,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct GrepArgs {
pub(crate) patterns: Option<Vec<String>>,
pub(crate) pattern: Option<String>,
pub(crate) path: Option<String>,
pub(crate) limit: Option<usize>,
pub(crate) offset: Option<usize>,
pub(crate) context: Option<usize>,
pub(crate) mode: Option<GrepMode>,
}
impl GrepArgs {
pub(crate) fn validate(mut self) -> anyhow::Result<Self> {
if self.patterns.is_some() && self.pattern.is_some() {
anyhow::bail!("provide either patterns or legacy pattern, not both");
}
if self.patterns.is_none() {
self.patterns = self.pattern.as_ref().map(|p| vec![p.clone()]);
}
let patterns = self
.patterns
.as_mut()
.ok_or_else(|| anyhow::anyhow!("patterns is required"))?;
if patterns.is_empty() || patterns.len() > GREP_MAX_PATTERNS {
anyhow::bail!("patterns must contain 1 to {GREP_MAX_PATTERNS} items");
}
let mut total = 0;
for (index, pattern) in patterns.iter().enumerate() {
if pattern.trim().is_empty() {
anyhow::bail!("patterns[{index}] must not be blank");
}
if pattern.chars().any(char::is_control) {
anyhow::bail!("patterns[{index}] must not contain control characters");
}
if pattern.chars().count() > GREP_MAX_PATTERN_CHARS
|| pattern.len() > GREP_MAX_PATTERN_BYTES
{
anyhow::bail!("patterns[{index}] exceeds pattern bounds");
}
total += pattern.len();
}
if total > GREP_MAX_PATTERN_TOTAL_BYTES {
anyhow::bail!("patterns exceed 4096 UTF-8 bytes");
}
if self.limit.is_some_and(|n| n == 0 || n > GREP_MAX_LIMIT) {
anyhow::bail!("limit must be 1..{GREP_MAX_LIMIT}");
}
if self.offset.is_some_and(|n| n > GREP_MAX_OFFSET) {
anyhow::bail!("offset must be at most {GREP_MAX_OFFSET}");
}
if self.context.is_some_and(|n| n > GREP_MAX_CONTEXT) {
anyhow::bail!("context must be at most {GREP_MAX_CONTEXT}");
}
Ok(self)
}
}
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum AstGrepOperation {
#[default]
Search,
Outline,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct AstGrepArgs {
#[serde(default)]
pub(crate) operation: AstGrepOperation,
pub(crate) pattern: Option<String>,
pub(crate) language: Option<String>,
pub(crate) path: Option<String>,
pub(crate) rewrite: Option<String>,
pub(crate) limit: Option<usize>,
pub(crate) items: Option<String>,
pub(crate) view: Option<String>,
pub(crate) name: Option<String>,
pub(crate) symbol_type: Option<String>,
pub(crate) pub_members: Option<bool>,
}
impl AstGrepArgs {
pub(crate) fn validate(mut self) -> anyhow::Result<Self> {
match self.operation {
AstGrepOperation::Search => {
let pattern = self
.pattern
.as_mut()
.ok_or_else(|| anyhow::anyhow!("pattern is required for search"))?;
*pattern = pattern.trim().to_string();
if pattern.is_empty() {
anyhow::bail!("pattern must not be empty");
}
if self.items.is_some()
|| self.view.is_some()
|| self.name.is_some()
|| self.symbol_type.is_some()
|| self.pub_members.is_some()
{
anyhow::bail!(
"items, view, name, symbol_type and pub_members require operation outline"
);
}
}
AstGrepOperation::Outline => {
if self.pattern.is_some() || self.rewrite.is_some() {
anyhow::bail!("pattern and rewrite are not supported for outline");
}
if !matches!(
self.items.as_deref(),
None | Some("structure" | "exports" | "imports" | "all")
) {
anyhow::bail!("items must be structure, exports, imports or all");
}
if !matches!(
self.view.as_deref(),
None | Some("names" | "signatures" | "digest" | "expanded")
) {
anyhow::bail!("view must be names, signatures, digest or expanded");
}
for (label, value) in [("name", &self.name), ("symbol_type", &self.symbol_type)] {
if let Some(value) = value
&& (value.trim().is_empty() || value.len() > 512)
{
anyhow::bail!("{label} must contain 1–512 bytes");
}
}
if let Some(name) = &self.name {
regex::Regex::new(name)
.map_err(|error| anyhow::anyhow!("invalid name regex: {error}"))?;
}
}
}
if let Some(limit) = self.limit {
if limit == 0 {
anyhow::bail!("limit must be at least 1");
}
if limit > AST_GREP_MAX_LIMIT {
anyhow::bail!("limit must be at most {AST_GREP_MAX_LIMIT}");
}
}
if let Some(language) = &self.language
&& language.trim().is_empty()
{
anyhow::bail!("language must not be empty when provided");
}
self.rewrite = self
.rewrite
.take()
.filter(|rewrite| !rewrite.trim().is_empty());
Ok(self)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct FindArgs {
pub(crate) query: String,
pub(crate) path: Option<String>,
pub(crate) kind: Option<FindKindArg>,
pub(crate) limit: Option<usize>,
pub(crate) offset: Option<usize>,
}
impl FindArgs {
pub(crate) fn validate(mut self) -> anyhow::Result<Self> {
self.query = self.query.trim().to_string();
if self.query.is_empty() {
anyhow::bail!("query must not be empty");
}
if self.query.chars().count() > 512 {
anyhow::bail!("query must be at most 512 characters");
}
if self.limit == Some(0) {
anyhow::bail!("limit must be at least 1");
}
if self.limit.is_some_and(|limit| limit > FIND_MAX_LIMIT) {
anyhow::bail!("limit must be at most {FIND_MAX_LIMIT}");
}
Ok(self)
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum FindKindArg {
Files,
Directories,
Mixed,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SubagentsArgs {
pub(crate) tasks: Vec<SubagentTaskArgs>,
pub(crate) concurrency: Option<usize>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SubagentTaskArgs {
pub(crate) intent: String,
pub(crate) agent: Option<String>,
pub(crate) identity: Option<String>,
pub(crate) context: Option<String>,
pub(crate) cwd: Option<String>,
}
impl TryFrom<SubagentsArgs> for crate::subagents::SubagentsArgs {
type Error = anyhow::Error;
fn try_from(args: SubagentsArgs) -> anyhow::Result<Self> {
let tasks = args
.tasks
.into_iter()
.map(|task| {
if let Some(identity) = task.identity.as_deref() {
validate_subagent_identity_id(identity)?;
}
Ok(SubagentTask {
intent: task.intent,
agent: task.agent,
identity: task.identity,
context: task.context,
cwd: task.cwd.map(PathBuf::from),
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
Self::from_validated_parts(tasks, args.concurrency)
}
}
#[cfg(test)]
mod lsp_arg_tests {
use super::*;
#[test]
fn find_query_boundaries_count_characters() {
for query in ["a".repeat(512), "é".repeat(512)] {
assert!(
FindArgs {
query,
path: None,
kind: None,
limit: None,
offset: None
}
.validate()
.is_ok()
);
}
for query in ["a".repeat(513), "é".repeat(513)] {
assert!(
FindArgs {
query,
path: None,
kind: None,
limit: None,
offset: None
}
.validate()
.is_err()
);
}
}
}