localharness/builtins/
rename_file.rs1use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde_json::{json, Value};
11
12use crate::error::{Error, Result};
13use crate::filesystem::{EntryKind, SharedFilesystem};
14use crate::tools::{Tool, ToolContext};
15
16pub struct RenameFile {
17 fs: SharedFilesystem,
18}
19
20impl RenameFile {
21 pub fn new(fs: SharedFilesystem) -> Self {
22 Self { fs }
23 }
24}
25
26crate::tool_params! {
27 struct Args: serde {
30 from: req_str = "Current path.",
31 to: req_str = "New path.",
32 }
33}
34
35#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
36#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
37impl Tool for RenameFile {
38 fn name(&self) -> &str {
39 "rename_file"
40 }
41
42 fn description(&self) -> &str {
43 "Rename or move a file from `from` to `to`. On native, atomic \
44 when both paths are on the same filesystem. On OPFS, performs \
45 read + write + delete (not atomic but safe — original is only \
46 removed after the new path lands)."
47 }
48
49 fn input_schema(&self) -> Value {
50 Args::schema()
51 }
52
53 async fn execute(&self, args: Value, _ctx: Option<Arc<ToolContext>>) -> Result<Value> {
54 let args: Args = serde_json::from_value(args)
55 .map_err(|e| Error::bad_args("rename_file", format!("rename_file args: {e}")))?;
56 if args.from == args.to {
57 return Err(Error::bad_args("rename_file", "from and to are identical"));
58 }
59 if crate::builtins::is_protected_path(&args.from) {
61 return Err(crate::builtins::protected_path_error(&args.from));
62 }
63 if crate::builtins::is_protected_path(&args.to) {
64 return Err(crate::builtins::protected_path_error(&args.to));
65 }
66 if matches!(self.fs.metadata(&args.from).await, Ok(Some(m)) if m.kind == EntryKind::Directory) {
72 if let Ok(entries) = self.fs.walk(&args.from, None).await {
73 if let Some(hit) = entries
74 .iter()
75 .find(|e| crate::builtins::is_protected_path(&e.path))
76 {
77 return Err(crate::builtins::protected_path_error(&hit.path));
78 }
79 }
80 }
81 if matches!(self.fs.metadata(&args.to).await, Ok(Some(_))) {
87 return Err(Error::other(format!(
88 "destination '{}' already exists — delete it first to overwrite",
89 args.to
90 )));
91 }
92 self.fs.rename(&args.from, &args.to).await?;
93 Ok(json!({ "ok": true, "from": args.from, "to": args.to }))
94 }
95}
96
97#[cfg(test)]
98mod schema_tests {
99 use super::Args;
100 use serde_json::json;
101
102 #[test]
106 fn schema_is_byte_identical_to_the_frozen_original() {
107 let frozen = json!({
108 "type": "object",
109 "properties": {
110 "from": { "type": "string", "description": "Current path." },
111 "to": { "type": "string", "description": "New path." }
112 },
113 "required": ["from", "to"]
114 });
115 assert_eq!(Args::schema().to_string(), frozen.to_string());
116 }
117}
118
119#[cfg(all(test, feature = "native"))]
120mod tests {
121 use super::*;
122 use crate::filesystem::NativeFilesystem;
123
124 #[tokio::test]
125 async fn renames_a_file() {
126 let dir = std::env::temp_dir();
127 let from = dir.join(format!("rename_from_{}.txt", uuid::Uuid::new_v4()));
128 let to = dir.join(format!("rename_to_{}.txt", uuid::Uuid::new_v4()));
129 std::fs::write(&from, "hello").unwrap();
130 let tool = RenameFile::new(Arc::new(NativeFilesystem::new()));
131 let out = tool
132 .execute(
133 json!({"from": from.display().to_string(), "to": to.display().to_string()}),
134 None,
135 )
136 .await
137 .unwrap();
138 assert_eq!(out["ok"], json!(true));
139 assert!(!from.exists());
140 assert_eq!(std::fs::read_to_string(&to).unwrap(), "hello");
141 let _ = std::fs::remove_file(to);
142 }
143
144 #[tokio::test]
145 async fn rejects_identical_paths() {
146 let tool = RenameFile::new(Arc::new(NativeFilesystem::new()));
147 let res = tool
148 .execute(json!({"from": "x.txt", "to": "x.txt"}), None)
149 .await;
150 assert!(res.is_err());
151 }
152
153 #[tokio::test]
154 async fn refuses_to_clobber_existing_destination() {
155 let dir = std::env::temp_dir();
158 let from = dir.join(format!("rn_from_{}.txt", uuid::Uuid::new_v4()));
159 let to = dir.join(format!("rn_to_{}.txt", uuid::Uuid::new_v4()));
160 std::fs::write(&from, "SOURCE").unwrap();
161 std::fs::write(&to, "IMPORTANT").unwrap();
162 let tool = RenameFile::new(Arc::new(NativeFilesystem::new()));
163 let res = tool
164 .execute(
165 json!({"from": from.display().to_string(), "to": to.display().to_string()}),
166 None,
167 )
168 .await;
169 assert!(res.is_err(), "must refuse to clobber an existing destination");
170 assert_eq!(std::fs::read_to_string(&to).unwrap(), "IMPORTANT", "dest untouched");
171 assert_eq!(std::fs::read_to_string(&from).unwrap(), "SOURCE", "source untouched");
172 let _ = std::fs::remove_file(from);
173 let _ = std::fs::remove_file(to);
174 }
175
176 #[tokio::test]
180 async fn refuses_to_rename_a_dir_containing_the_seed() {
181 let base = std::env::temp_dir().join(format!("rn_dir_{}", uuid::Uuid::new_v4()));
182 let from = base.join("from");
183 std::fs::create_dir_all(&from).unwrap();
184 let seed = from.join(".lh_wallet");
185 std::fs::write(&seed, b"SECRET SEED PHRASE").unwrap();
186 let to = base.join("to");
187 let tool = RenameFile::new(Arc::new(NativeFilesystem::new()));
188 let res = tool
189 .execute(
190 json!({"from": from.display().to_string(), "to": to.display().to_string()}),
191 None,
192 )
193 .await;
194 assert!(res.is_err(), "must refuse to rename a dir holding the seed");
195 assert!(seed.exists(), "seed must stay put after the refused rename");
196 assert!(!to.exists(), "destination must not be created");
197 std::fs::remove_dir_all(&base).ok();
198 }
199}