Skip to main content

sgr_agent_tools/
prepend.rs

1//! PrependTool — prepend header text to a file without touching the body.
2//!
3//! Reads the file, prepends header, writes back. Body is byte-perfect —
4//! never passes through LLM context. Used for adding YAML frontmatter.
5
6use std::sync::Arc;
7
8use schemars::JsonSchema;
9use serde::Deserialize;
10use serde_json::Value;
11use sgr_agent_core::agent_tool::{Tool, ToolError, ToolOutput, parse_args};
12use sgr_agent_core::context::AgentContext;
13use sgr_agent_core::schema::json_schema_for;
14
15use crate::backend::FileBackend;
16use crate::helpers::backend_err;
17
18pub struct PrependTool<B: FileBackend>(pub Arc<B>);
19
20#[derive(Deserialize, JsonSchema)]
21struct PrependArgs {
22    /// File path to prepend to
23    path: String,
24    /// Text to prepend (e.g. YAML frontmatter block). Will be followed by a newline before existing content.
25    header: String,
26}
27
28#[async_trait::async_trait]
29impl<B: FileBackend> Tool for PrependTool<B> {
30    fn name(&self) -> &str {
31        "prepend_to_file"
32    }
33    fn description(&self) -> &str {
34        "Prepend header text to a file. Body is preserved byte-for-byte (never re-typed). \
35         Use for adding YAML frontmatter to existing files without risking body corruption."
36    }
37    fn parameters_schema(&self) -> Value {
38        json_schema_for::<PrependArgs>()
39    }
40    async fn execute(&self, args: Value, _ctx: &mut AgentContext) -> Result<ToolOutput, ToolError> {
41        let a: PrependArgs = parse_args(&args)?;
42
43        // Read existing content
44        let raw = self
45            .0
46            .read(&a.path, false, 0, 0)
47            .await
48            .map_err(backend_err)?;
49        // Strip PCM header ("$ cat path\n") if present
50        let body = if raw.starts_with("$ ") {
51            raw.find('\n').map(|i| &raw[i + 1..]).unwrap_or(&raw)
52        } else {
53            &raw
54        };
55
56        // Prepend header + newline + original body
57        let header = a.header.trim_end_matches('\n');
58        let combined = format!("{}\n{}", header, body);
59
60        self.0
61            .write(&a.path, &combined, 0, 0)
62            .await
63            .map_err(backend_err)?;
64
65        Ok(ToolOutput::text(format!(
66            "Prepended {} bytes to {} (body preserved)",
67            header.len(),
68            a.path
69        )))
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::mock_fs::MockFs;
77    use sgr_agent_core::agent_tool::Tool;
78
79    #[tokio::test]
80    async fn test_prepend_frontmatter() {
81        let fs = Arc::new(MockFs::new());
82        fs.add_file("doc.md", "# My Document\n\nSome content here.");
83        let tool = PrependTool(fs.clone());
84        let mut ctx = AgentContext::new();
85        let result = tool
86            .execute(
87                serde_json::json!({
88                    "path": "doc.md",
89                    "header": "---\ntitle: My Document\ntype: note\n---"
90                }),
91                &mut ctx,
92            )
93            .await
94            .unwrap();
95        assert!(result.content.contains("Prepended"));
96        let content = fs.content("doc.md").unwrap();
97        assert!(content.starts_with("---\ntitle: My Document"));
98        assert!(content.contains("# My Document\n\nSome content here."));
99    }
100
101    #[tokio::test]
102    async fn test_prepend_to_empty() {
103        let fs = Arc::new(MockFs::new());
104        fs.add_file("empty.md", "");
105        let tool = PrependTool(fs.clone());
106        let mut ctx = AgentContext::new();
107        let _ = tool
108            .execute(
109                serde_json::json!({"path": "empty.md", "header": "---\ntitle: New\n---"}),
110                &mut ctx,
111            )
112            .await
113            .unwrap();
114        let content = fs.content("empty.md").unwrap();
115        assert!(content.starts_with("---\ntitle: New\n---"));
116    }
117}