1use std::path::PathBuf;
2
3use crate::error::RuntimeError;
4use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
5use crate::tools::anchor_fs::{self, AnchorError, StateStore};
6use crate::value::Value;
7
8pub struct AnchorRead;
9pub struct AnchorEdit;
10pub struct AnchorWrite;
11pub struct AnchorUndo;
12
13fn text(args: &ToolArgs, name: &str, required: bool) -> Result<Option<String>, RuntimeError> {
14 match args.named(name) {
15 Some(Value::Str(value)) => Ok(Some(value.clone())),
16 Some(Value::Unit) if !required => Ok(None),
17 None if !required => Ok(None),
18 Some(Value::Unit) | None => Err(RuntimeError::MissingArg(name.into())),
19 Some(value) => Err(RuntimeError::TypeMismatch {
20 expected: "string".into(),
21 actual: value.kind_name().into(),
22 }),
23 }
24}
25
26fn path(args: &ToolArgs) -> Result<PathBuf, RuntimeError> {
27 match args.named("path").or_else(|| args.positional.first()) {
28 Some(Value::Path(path)) => Ok(path.clone()),
29 Some(Value::Str(path)) => Ok(PathBuf::from(path)),
30 Some(value) => Err(RuntimeError::TypeMismatch {
31 expected: "path or string".into(),
32 actual: value.kind_name().into(),
33 }),
34 None => Err(RuntimeError::MissingArg("path".into())),
35 }
36}
37
38fn store(ctx: &ToolCtx) -> StateStore {
39 StateStore::new(
40 ctx.data_root
41 .clone()
42 .unwrap_or_else(|| PathBuf::from(".atman"))
43 .join("anchor"),
44 )
45}
46
47fn error(error: AnchorError) -> RuntimeError {
48 RuntimeError::ToolFailed(error.to_string())
49}
50
51fn change_value(change: &anchor_fs::ChangeRecord) -> Value {
52 Value::Struct(vec![
53 ("change_id".into(), Value::Str(change.change_id.clone())),
54 ("path".into(), Value::Str(change.path.clone())),
55 ("before_hash".into(), Value::Str(change.before_hash.clone())),
56 ("after_hash".into(), Value::Str(change.after_hash.clone())),
57 ])
58}
59
60fn schema(properties: serde_json::Value, required: &[&str]) -> serde_json::Value {
61 serde_json::json!({"type":"object", "properties":properties, "required":required})
62}
63
64impl Tool for AnchorRead {
65 fn name(&self) -> &str {
66 "anchor.read"
67 }
68 fn tier(&self) -> Tier {
69 Tier::Zero
70 }
71 fn description(&self) -> Option<&str> {
72 Some("Read a file with stable hashline anchors.")
73 }
74 fn input_schema(&self) -> serde_json::Value {
75 schema(
76 serde_json::json!({"path":{"type":["string","object"]}}),
77 &["path"],
78 )
79 }
80 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
81 Box::pin(async move {
82 let p = ctx.resolve_path(&path(&args)?)?;
83 anchor_fs::read_anchor_text(&p, &store(ctx))
84 .map(Value::Str)
85 .map_err(error)
86 })
87 }
88}
89
90impl Tool for AnchorEdit {
91 fn name(&self) -> &str {
92 "anchor.edit"
93 }
94 fn tier(&self) -> Tier {
95 Tier::Two
96 }
97 fn approval_level(&self, _: &ToolArgs, _: &ToolCtx) -> ApprovalLevel {
98 ApprovalLevel::Approve
99 }
100 fn description(&self) -> Option<&str> {
101 Some("Apply a strict replace, insert, or remove mutation using hashline anchors.")
102 }
103 fn input_schema(&self) -> serde_json::Value {
104 schema(
105 serde_json::json!({"path":{"type":"string"},"operation":{"enum":["replace","insert","remove"]},"target":{"type":"string"},"from":{"type":"string"},"to":{"type":"string"},"at":{"type":"string"},"position":{"enum":["before","after"]},"content":{"type":"string"}}),
106 &["path", "operation"],
107 )
108 }
109 fn invocation_provenance(
110 &self,
111 args: &ToolArgs,
112 ctx: &ToolCtx,
113 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
114 Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
115 .with_path(ctx, &path(args)?)?
116 .with_risk(crate::trust::RiskKind::FilesystemWrite))
117 }
118
119 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
120 Box::pin(async move {
121 let p = ctx.resolve_path_with_origin(&path(&args)?)?.path;
122 crate::fs_access::authorize_write(ctx, &p, self.name(), true).await?;
123 let operation = text(&args, "operation", true)?.unwrap();
124 let change = anchor_fs::edit_by_anchor(
125 &p,
126 &operation,
127 text(&args, "target", false)?.as_deref(),
128 text(&args, "from", false)?.as_deref(),
129 text(&args, "to", false)?.as_deref(),
130 text(&args, "at", false)?.as_deref(),
131 text(&args, "position", false)?.as_deref(),
132 text(&args, "content", false)?.as_deref(),
133 &store(ctx),
134 )
135 .map_err(error)?;
136 Ok(change_value(&change))
137 })
138 }
139}
140
141impl Tool for AnchorWrite {
142 fn name(&self) -> &str {
143 "anchor.write"
144 }
145 fn tier(&self) -> Tier {
146 Tier::Two
147 }
148 fn approval_level(&self, _: &ToolArgs, _: &ToolCtx) -> ApprovalLevel {
149 ApprovalLevel::Approve
150 }
151 fn description(&self) -> Option<&str> {
152 Some("Overwrite a file only when its expected hash matches.")
153 }
154 fn input_schema(&self) -> serde_json::Value {
155 schema(
156 serde_json::json!({"path":{"type":"string"},"expected_file_hash":{"type":"string"},"content":{"type":"string"}}),
157 &["path", "expected_file_hash", "content"],
158 )
159 }
160 fn invocation_provenance(
161 &self,
162 args: &ToolArgs,
163 ctx: &ToolCtx,
164 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
165 Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
166 .with_path(ctx, &path(args)?)?
167 .with_risk(crate::trust::RiskKind::FilesystemWrite))
168 }
169
170 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
171 Box::pin(async move {
172 let p = ctx.resolve_path_with_origin(&path(&args)?)?.path;
173 crate::fs_access::authorize_write(ctx, &p, self.name(), true).await?;
174 let h = text(&args, "expected_file_hash", true)?.unwrap();
175 let c = text(&args, "content", true)?.unwrap();
176 anchor_fs::overwrite_with_hash(&p, &h, &c, &store(ctx))
177 .map(|v| change_value(&v))
178 .map_err(error)
179 })
180 }
181}
182
183impl Tool for AnchorUndo {
184 fn name(&self) -> &str {
185 "anchor.undo"
186 }
187 fn tier(&self) -> Tier {
188 Tier::Two
189 }
190 fn approval_level(&self, _: &ToolArgs, _: &ToolCtx) -> ApprovalLevel {
191 ApprovalLevel::Approve
192 }
193 fn description(&self) -> Option<&str> {
194 Some("Undo the latest anchor change, refusing if the file changed since.")
195 }
196 fn input_schema(&self) -> serde_json::Value {
197 schema(
198 serde_json::json!({"path":{"type":"string"},"change_id":{"type":"string"}}),
199 &["path"],
200 )
201 }
202 fn invocation_provenance(
203 &self,
204 args: &ToolArgs,
205 ctx: &ToolCtx,
206 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
207 Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
208 .with_path(ctx, &path(args)?)?
209 .with_risk(crate::trust::RiskKind::FilesystemWrite))
210 }
211
212 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
213 Box::pin(async move {
214 let p = ctx.resolve_path_with_origin(&path(&args)?)?.path;
215 crate::fs_access::authorize_write(ctx, &p, self.name(), true).await?;
216 let s = store(ctx);
217 let id = text(&args, "change_id", false)?;
218 let change = match id {
219 Some(id) => s.change(&id),
220 None => s.latest_change(&p),
221 }
222 .map_err(error)?
223 .ok_or_else(|| {
224 RuntimeError::ToolFailed(format!("no anchor change for {}", p.display()))
225 })?;
226 let current = std::fs::read(&p).map_err(|e| RuntimeError::ToolFailed(e.to_string()))?;
227 s.undo_strict(&p, &change, ¤t).map_err(error)?;
228 Ok(change_value(&change))
229 })
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[tokio::test]
238 async fn anchor_write_rejects_external_path_without_changing_file() {
239 let workspace = tempfile::tempdir().unwrap();
240 let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
241 .join("target")
242 .join(format!("r4-anchor-{}.txt", uuid::Uuid::now_v7()));
243 std::fs::create_dir_all(fixture.parent().unwrap()).unwrap();
244 std::fs::write(&fixture, "original").unwrap();
245 let ctx = ToolCtx::new()
246 .with_fs_access(crate::fs_access::FsAccessPolicy::workspace_write(
247 workspace.path().into(),
248 ))
249 .with_workspace(crate::git_workspace::WorkspaceBinding {
250 workspace_id: "test".into(),
251 repository_root: workspace.path().into(),
252 path: workspace.path().into(),
253 branch: None,
254 });
255 let error = AnchorWrite
256 .call(
257 ToolArgs {
258 positional: vec![],
259 named: vec![
260 ("path".into(), Value::Path(fixture.clone())),
261 ("expected_file_hash".into(), Value::Str("unused".into())),
262 ("content".into(), Value::Str("changed".into())),
263 ],
264 },
265 &ctx,
266 )
267 .await
268 .unwrap_err();
269 assert!(error.to_string().contains("outside workspace"));
270 assert_eq!(std::fs::read_to_string(&fixture).unwrap(), "original");
271 std::fs::remove_file(fixture).unwrap();
272 }
273}