coro_core/tools/
utils.rs

1//! Utility functions for tools
2
3pub mod run;
4
5use crate::error::Result;
6use std::path::Path;
7use tokio::process::Command;
8use tokio::time::{timeout, Duration};
9
10pub use run::{
11    execute_command, stream_command, validate_command_safety, CommandOptions, CommandResult,
12};
13
14/// Maximum response length before truncation
15pub const MAX_RESPONSE_LEN: usize = 16000;
16
17/// Truncation message
18pub const TRUNCATED_MESSAGE: &str = "<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>";
19
20/// Truncate content if it exceeds the specified length
21pub fn maybe_truncate(content: &str, truncate_after: Option<usize>) -> String {
22    let limit = truncate_after.unwrap_or(MAX_RESPONSE_LEN);
23    if content.len() <= limit {
24        content.to_string()
25    } else {
26        format!("{}{}", &content[..limit], TRUNCATED_MESSAGE)
27    }
28}
29
30/// Run a shell command asynchronously with timeout
31pub async fn run_command(
32    cmd: &str,
33    timeout_secs: Option<u64>,
34    truncate_after: Option<usize>,
35) -> Result<(i32, String, String)> {
36    let timeout_duration = Duration::from_secs(timeout_secs.unwrap_or(120));
37
38    let result = timeout(timeout_duration, async {
39        let output = Command::new("sh").arg("-c").arg(cmd).output().await?;
40
41        let exit_code = output.status.code().unwrap_or(-1);
42        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
43        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
44
45        Ok::<(i32, String, String), std::io::Error>((exit_code, stdout, stderr))
46    })
47    .await;
48
49    match result {
50        Ok(Ok((exit_code, stdout, stderr))) => Ok((
51            exit_code,
52            maybe_truncate(&stdout, truncate_after),
53            maybe_truncate(&stderr, truncate_after),
54        )),
55        Ok(Err(e)) => Err(e.into()),
56        Err(_) => Err(format!(
57            "Command '{}' timed out after {} seconds",
58            cmd,
59            timeout_secs.unwrap_or(120)
60        )
61        .into()),
62    }
63}
64
65/// Format file content with line numbers
66pub fn format_with_line_numbers(content: &str, start_line: usize) -> String {
67    content
68        .lines()
69        .enumerate()
70        .map(|(i, line)| format!("{:6}\t{}", i + start_line, line))
71        .collect::<Vec<_>>()
72        .join("\n")
73}
74
75/// Validate that a path is absolute
76pub fn validate_absolute_path(path: &Path) -> Result<()> {
77    if !path.is_absolute() {
78        let suggested_path = Path::new("/").join(path);
79        return Err(format!(
80            "The path {} is not an absolute path, it should start with `/`. Maybe you meant {}?",
81            path.display(),
82            suggested_path.display()
83        )
84        .into());
85    }
86    Ok(())
87}
88
89/// Check if a file exists and return appropriate error
90pub fn check_file_exists(path: &Path, operation: &str) -> Result<()> {
91    match operation {
92        "create" => {
93            if path.exists() {
94                return Err(format!(
95                    "File already exists at: {}. Cannot overwrite files using command `create`.",
96                    path.display()
97                )
98                .into());
99            }
100        }
101        _ => {
102            if !path.exists() {
103                return Err(format!(
104                    "The path {} does not exist. Please provide a valid path.",
105                    path.display()
106                )
107                .into());
108            }
109        }
110    }
111    Ok(())
112}
113
114/// Check if path is a directory and validate operation
115pub fn validate_directory_operation(path: &Path, operation: &str) -> Result<()> {
116    if path.is_dir() && operation != "view" {
117        return Err(format!(
118            "The path {} is a directory and only the `view` command can be used on directories",
119            path.display()
120        )
121        .into());
122    }
123    Ok(())
124}
125
126/// Expand tabs in text content
127pub fn expand_tabs(content: &str) -> String {
128    content.replace('\t', "    ")
129}
130
131/// Create a snippet around a specific line for editing feedback
132pub fn create_edit_snippet(content: &str, target_line: usize, snippet_lines: usize) -> String {
133    let lines: Vec<&str> = content.lines().collect();
134    let start_line = target_line.saturating_sub(snippet_lines);
135    let end_line = std::cmp::min(target_line + snippet_lines + 1, lines.len());
136
137    lines[start_line..end_line]
138        .iter()
139        .enumerate()
140        .map(|(i, line)| format!("{:6}\t{}", start_line + i + 1, line))
141        .collect::<Vec<_>>()
142        .join("\n")
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_maybe_truncate() {
151        let short_content = "Hello, world!";
152        assert_eq!(maybe_truncate(short_content, Some(20)), short_content);
153
154        let long_content = "a".repeat(100);
155        let truncated = maybe_truncate(&long_content, Some(50));
156        assert!(truncated.len() > 50);
157        assert!(truncated.contains(TRUNCATED_MESSAGE));
158    }
159
160    #[test]
161    fn test_format_with_line_numbers() {
162        let content = "line1\nline2\nline3";
163        let formatted = format_with_line_numbers(content, 10);
164        assert!(formatted.contains("    10\tline1"));
165        assert!(formatted.contains("    11\tline2"));
166        assert!(formatted.contains("    12\tline3"));
167    }
168
169    #[test]
170    fn test_expand_tabs() {
171        let content = "hello\tworld\t!";
172        let expanded = expand_tabs(content);
173        assert_eq!(expanded, "hello    world    !");
174    }
175}