Skip to main content

sgr_agent_tools/
copy.rs

1//! CopyTool — copy a file without LLM in the loop (byte-perfect).
2
3use std::sync::Arc;
4
5use schemars::JsonSchema;
6use serde::Deserialize;
7use serde_json::Value;
8use sgr_agent_core::agent_tool::{Tool, ToolError, ToolOutput, parse_args};
9use sgr_agent_core::context::AgentContext;
10use sgr_agent_core::schema::json_schema_for;
11
12use crate::backend::FileBackend;
13use crate::helpers::backend_err;
14
15pub struct CopyTool<B: FileBackend>(pub Arc<B>);
16
17#[derive(Deserialize, JsonSchema)]
18struct CopyArgs {
19    /// Source file path
20    source: String,
21    /// Destination file path (can be same as source for in-place rewrite)
22    target: String,
23}
24
25#[async_trait::async_trait]
26impl<B: FileBackend> Tool for CopyTool<B> {
27    fn name(&self) -> &str {
28        "copy_file"
29    }
30    fn description(&self) -> &str {
31        "Copy a file byte-for-byte. Use instead of read+write when content must be preserved verbatim (long docs, invoices, migration)"
32    }
33    fn parameters_schema(&self) -> Value {
34        json_schema_for::<CopyArgs>()
35    }
36    async fn execute(&self, args: Value, _ctx: &mut AgentContext) -> Result<ToolOutput, ToolError> {
37        let a: CopyArgs = parse_args(&args)?;
38        let content = self
39            .0
40            .read(&a.source, false, 0, 0)
41            .await
42            .map_err(backend_err)?;
43        // Strip PCM header ("$ cat path\n") if present
44        let body = if content.starts_with("$ ") {
45            content
46                .find('\n')
47                .map(|i| &content[i + 1..])
48                .unwrap_or(&content)
49        } else {
50            &content
51        };
52        self.0
53            .write(&a.target, body, 0, 0)
54            .await
55            .map_err(backend_err)?;
56        let bytes = body.len();
57        Ok(ToolOutput::text(format!(
58            "Copied {} → {} ({} bytes)",
59            a.source, a.target, bytes
60        )))
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::mock_fs::MockFs;
68    use sgr_agent_core::agent_tool::Tool;
69
70    #[tokio::test]
71    async fn test_copy_file() {
72        let fs = Arc::new(MockFs::new());
73        fs.add_file("src.md", "# Hello\n\nLong content here...");
74        let tool = CopyTool(fs.clone());
75        let mut ctx = AgentContext::new();
76        let result = tool
77            .execute(
78                serde_json::json!({"source": "src.md", "target": "dst.md"}),
79                &mut ctx,
80            )
81            .await
82            .unwrap();
83        assert!(result.content.contains("Copied src.md → dst.md"));
84        assert!(
85            fs.content("dst.md")
86                .unwrap()
87                .starts_with("# Hello\n\nLong content here...")
88        );
89        // Source preserved
90        assert!(fs.exists("src.md"));
91    }
92
93    #[tokio::test]
94    async fn test_copy_in_place() {
95        let fs = Arc::new(MockFs::new());
96        fs.add_file("doc.md", "original content");
97        let tool = CopyTool(fs.clone());
98        let mut ctx = AgentContext::new();
99        let result = tool
100            .execute(
101                serde_json::json!({"source": "doc.md", "target": "doc.md"}),
102                &mut ctx,
103            )
104            .await
105            .unwrap();
106        assert!(result.content.contains("Copied doc.md → doc.md"));
107        assert!(
108            fs.content("doc.md")
109                .unwrap()
110                .starts_with("original content")
111        );
112    }
113}