use crate::tools::ReadFileMode;
pub(crate) const READ_FILE_BYTES_PER_TOKEN: usize = 4;
pub(crate) const READ_FILE_HARD_MAX_BYTES: usize = 1024 * 1024;
pub(crate) const READ_FILE_DEFAULT_PREVIEW_LINES: usize = 800;
pub(crate) const READ_FILE_MAX_LINE_WINDOW: usize = 10_000;
pub(crate) const READ_FILE_STDERR_SNIPPET_LIMIT_CHARS: usize = 256;
pub(crate) const READ_FILE_SIZE_MARKER: &str = "__SSH_MCP_READ_FILE_SIZE__";
pub(crate) const READ_FILE_TRUNC_MARKER: &str = "__SSH_MCP_READ_FILE_TRUNC__";
pub(crate) fn resolve_read_file_max_bytes(max_output_tokens: Option<usize>) -> usize {
max_output_tokens
.and_then(|tokens| tokens.checked_mul(READ_FILE_BYTES_PER_TOKEN))
.filter(|bytes| *bytes > 0)
.unwrap_or(READ_FILE_HARD_MAX_BYTES)
.min(READ_FILE_HARD_MAX_BYTES)
}
pub(crate) fn estimate_tokens_from_bytes(byte_len: usize) -> usize {
byte_len.saturating_add(READ_FILE_BYTES_PER_TOKEN.saturating_sub(1)) / READ_FILE_BYTES_PER_TOKEN
}
pub(crate) fn resolve_read_file_line_limit(
mode: ReadFileMode,
lines: Option<usize>,
) -> std::result::Result<Option<usize>, String> {
match mode {
ReadFileMode::Full => Ok(None),
ReadFileMode::Preview | ReadFileMode::Head | ReadFileMode::Tail => {
let value = lines.unwrap_or(READ_FILE_DEFAULT_PREVIEW_LINES);
if value == 0 {
return Err("lines must be a positive integer".to_string());
}
if value > READ_FILE_MAX_LINE_WINDOW {
return Err(format!("lines must be <= {READ_FILE_MAX_LINE_WINDOW}"));
}
Ok(Some(value))
}
}
}
pub(crate) fn read_file_line_count(content: &str) -> usize {
if content.is_empty() {
return 0;
}
let newline_count = content.bytes().filter(|byte| *byte == b'\n').count();
if content.ends_with('\n') {
newline_count
} else {
newline_count.saturating_add(1)
}
}
pub(crate) fn parse_read_file_size_marker(stderr: &str) -> Option<usize> {
stderr.lines().find_map(|line| {
line.trim()
.strip_prefix(READ_FILE_SIZE_MARKER)
.and_then(|rest| rest.trim().parse::<usize>().ok())
})
}
pub(crate) fn read_file_stderr_indicates_truncated(stderr: &str) -> bool {
stderr
.lines()
.any(|line| line.trim().starts_with(READ_FILE_TRUNC_MARKER))
}
pub(crate) fn build_read_file_hint(
mode: ReadFileMode,
line_limit: usize,
truncated: bool,
) -> Option<String> {
match mode {
ReadFileMode::Preview => Some(format!(
"Preview mode returns up to {line_limit} lines. Re-run with mode=\"full\" to read the entire file, or mode=\"tail\" to inspect the file end"
)),
ReadFileMode::Head | ReadFileMode::Tail if truncated => Some(format!(
"Output truncated to {line_limit} lines in {} mode. Re-run with mode=\"full\" to read the entire file",
mode.as_str()
)),
_ => None,
}
}
pub(crate) fn read_file_too_large_error(max_bytes: usize) -> String {
format!(
"Error: remote file exceeds read size limit ({max_bytes} bytes). Use transfer for large files"
)
}
pub(crate) fn sanitize_read_file_stderr_snippet(stderr: &str) -> Option<String> {
let mut snippet = String::new();
let mut truncated = false;
let mut prev_space = false;
let mut char_count = 0usize;
for ch in stderr.trim().chars() {
if char_count >= READ_FILE_STDERR_SNIPPET_LIMIT_CHARS {
truncated = true;
break;
}
let normalized = if ch.is_control() { ' ' } else { ch };
if normalized.is_whitespace() {
if !prev_space {
snippet.push(' ');
prev_space = true;
char_count = char_count.saturating_add(1);
}
} else {
snippet.push(normalized);
prev_space = false;
char_count = char_count.saturating_add(1);
}
}
let mut normalized = snippet.trim().to_string();
if normalized.is_empty() {
return None;
}
if truncated {
normalized.push_str("...");
}
Some(normalized)
}
pub(crate) fn build_read_file_remote_failure(exit_code: Option<u32>, stderr: &str) -> String {
let mut message = match exit_code {
Some(code) => format!("Error reading file: remote command failed with exit_code={code}"),
None => "Error reading file: remote command did not provide an exit status".to_string(),
};
if let Some(snippet) = sanitize_read_file_stderr_snippet(stderr) {
message.push_str(&format!("; stderr={snippet}"));
}
message
}