Skip to main content

cosh_tools/fs/
mod.rs

1//! Shared-state wrapper for file-system tool operations.
2//!
3//! [`Fs`] holds the project root and write-scope guards so callers don't
4//! have to construct [`FsMetadata`] on every invocation.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use cosh_tools::fs::Fs;
10//!
11//! let fs = Fs::new().cwd("/home/user/project");
12//! fs.write(vec![TargetFile { path: "foo.txt".to_string(), text: "hello".to_string()  file_hash: None, }]).await;
13//! ```
14
15pub mod ast_edit;
16pub mod edit;
17pub mod fuzzy;
18#[cfg(test)]
19mod fuzzy_equivalence;
20pub mod read;
21pub mod replace;
22pub mod rollback;
23#[cfg(test)]
24mod test;
25pub mod types;
26pub mod write;
27
28use std::{path::PathBuf, sync::Arc, time::Duration};
29
30pub use edit::{EditBatchError, EditResult, edit};
31pub use read::{ReadResult, read};
32pub use rollback::{RollbackResult, rollback};
33pub use types::{
34    AstEditOp, EditTarget, FsAstEdit, FsContentEdit, FsEdit, FsMetadata, FsRead, FsRollback,
35    FsRollbackInput, FsWrite, LspNote, LspNotes, ReplaceEdit, Target, TargetFile,
36};
37pub use write::{WriteResult, write};
38
39use crate::{ToolDescription, lsp::Lsp};
40use cosh_sdk::lsp::lsp_types::DiagnosticSeverity;
41
42/// Quiet-period budget for passive LSP feedback after a mutation.
43const LSP_SETTLE: Duration = Duration::from_secs(2);
44/// Per-server deadline for the post-mutation diagnostics pull, covering
45/// `ServerCancelled` retries while the server is still analyzing.
46const LSP_REACTION: Duration = Duration::from_secs(15);
47/// Per-request timeout of a single diagnostics pull.
48const LSP_PULL_TIMEOUT_SECS: u64 = 10;
49/// Hard cap per severity in passive LSP feedback.
50const LSP_MAX_NOTES: usize = 20;
51
52/// Shared-state wrapper for file-system tool operations.
53///
54/// Use the builder methods after [`new`](Self::new) to configure the
55/// project root and scope guards, then call the operation methods
56/// directly.
57pub struct Fs {
58    root: PathBuf,
59
60    /// Restricts write operations to the specified paths.
61    /// Your frontend should request confirmation before setting this field.
62    allowlist: Option<Vec<PathBuf>>,
63    blocklist: Option<Vec<PathBuf>>,
64
65    /// Read-only path allowlist (optional, falls back to `allowlist`).
66    /// Paths listed here are readable but not writable.
67    /// Set this to grant read access outside the project root without
68    /// granting write access to the same paths.
69    read_allowlist: Option<Vec<PathBuf>>,
70    /// Read-only path blocklist (optional, falls back to `blocklist`).
71    read_blocklist: Option<Vec<PathBuf>>,
72
73    /// Language-server engine for passive diagnostics. Its presence is the
74    /// toggle: set once via [`Fs::with_lsp`], every mutating operation then
75    /// reports findings on the touched files.
76    lsp: Option<Arc<Lsp>>,
77
78    /// MCP Tool description for `read`.
79    pub description_read: ToolDescription,
80    /// MCP Tool description for `write`.
81    pub description_write: ToolDescription,
82    /// MCP Tool description for `edit` (content replace).
83    pub description_edit: ToolDescription,
84    /// MCP Tool description for `fs_edit_lines` (hashline replace).
85    pub description_edit_lines: ToolDescription,
86    /// MCP Tool description for `fs_ast_edit` (AST structural).
87    pub description_ast_edit: ToolDescription,
88    /// MCP Tool description for `rollback`.
89    pub description_rollback: ToolDescription,
90}
91
92impl Default for Fs {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl Fs {
99    /// Create a new `Fs` with no root path set.
100    ///
101    /// All paths are denied until [`cwd`](Self::cwd) is called.
102    #[must_use]
103    pub fn new() -> Self {
104        Self {
105            root: PathBuf::new(),
106            allowlist: None,
107            blocklist: None,
108            read_allowlist: None,
109            read_blocklist: None,
110            lsp: None,
111            description_read: serde_json::json!({
112                "name": "fs_read",
113                "description": concat!(
114                    "Read one file, named symbols, or a range of lines from ",
115                    "the project. The call can specify: a path with an optional ",
116                    "`offset` (1-based line number to start reading from — reads ",
117                    "the syntactic block containing that line when used without ",
118                    "`limit`), an optional `limit` (number of lines to read; only ",
119                    "provide together with `offset`, if the file is too large to ",
120                    "read at once), or an optional `symbol` name (function, class, ",
121                    "variable) to look up. `symbol` takes precedence over ",
122                    "`offset`/`limit`. Every result carries a \u{00b6}path#TAG ",
123                    "header; lines are numbered `N| text` so edits can anchor ",
124                    "directly. After an fs_edit the response also carries the ",
125                    "updated header — you only need to re-read when you want ",
126                    "to SEE new content, not to edit again. Blocks larger ",
127                    "than 24 lines are elided to their head/tail with a footer ",
128                    "columns are truncated with `...` and flagged. After a range ",
129                    "read a footer reports how many lines remain and how to ",
130                    "continue — read only what you need instead of whole files."
131                ),
132                "inputSchema": {
133                    "type": "object",
134                    "additionalProperties": false,
135                    "properties": {
136                        "path": {
137                            "type": "string",
138                            "description": "Path to the file to read, relative to the project root"
139                        },
140                        "offset": {
141                            "type": "integer",
142                            "description": concat!(
143                                "Optional 1-based line number to start reading from. Only ",
144                                "provide if the file is too large to read at once. Without ",
145                                "`limit`, reads the syntactic block containing that line; ",
146                                "with `limit`, reads exactly offset..offset+limit-1"
147                            )
148                        },
149                        "limit": {
150                            "type": "integer",
151                            "description": concat!(
152                                "Optional number of lines to read. Only provide together ",
153                                "with `offset`, if the file is too large to read at once"
154                            )
155                        },
156                        "symbol": {
157                            "type": "string",
158                            "description": concat!(
159                                "Optional symbol name to look up ",
160                                "(function, class, variable) within the file"
161                            )
162                        }
163                    },
164                    "required": ["path"]
165                }
166            }),
167            description_write: serde_json::json!({
168                "name": "fs_write",
169                "description": concat!(
170                    "Write content to one file. Creates new files or overwrites ",
171                    "existing ones entirely.\n\n",
172                    "IMPORTANT: When overwriting an existing file, you MUST include the ",
173                    "`file_hash` from a previous `fs_read` call. This proves you have ",
174                    "read the file before overwriting it. If you omit `file_hash` on an ",
175                    "existing file, the write will be rejected. For new files (that do ",
176                    "not yet exist), `file_hash` is not needed."
177                ),
178                "inputSchema": {
179                    "type": "object",
180                    "properties": {
181                        "path": {
182                            "type": "string",
183                            "description": "Path to the file to write, relative to the project root"
184                        },
185                        "content": {
186                            "type": "string",
187                            "description": "Full text content to write to the file"
188                        },
189                        "file_hash": {
190                            "type": ["string", "null"],
191                            "description": concat!(
192                                "4-hex content hash tag from `fs_read` OR a previous ",
193                                "fs_edit result (the \u{00B6}path#TAG header). REQUIRED when ",
194                                "overwriting an existing file to prove you have read its ",
195                                "current content. Omit or set to null for new files."
196                            )
197                        }
198                    },
199                    "required": ["path", "content"]
200                }
201            }),
202            description_edit: Self::description_edit(),
203            description_edit_lines: Self::description_edit_lines(),
204            description_ast_edit: Self::description_edit_ast(),
205            description_rollback: serde_json::json!({
206                "name": "fs_rollback",
207                "description": concat!(
208                    "Roll back a file to a previously recorded session version: ",
209                    "restore the state identified by the given `hash` (a 4-hex tag ",
210                    "from an earlier fs_read/fs_edit/rollback result for that path). ",
211                    "Pass an empty `hash` to restore the version immediately ",
212                    "preceding the current content. Returns an error if the path ",
213                    "has no rollback history, if the `hash` is not found in it, or ",
214                    "if the file was modified externally since the snapshot."
215                ),
216                "inputSchema": {
217                    "type": "object",
218                    "properties": {
219                        "path": {
220                            "type": "string",
221                            "description": "Path to the file to roll back, relative to the project root"
222                        },
223                        "hash": {
224                            "type": "string",
225                            "description": "Session hash identifying which version to restore"
226                        }
227                    },
228                    "required": ["path", "hash"]
229                }
230            }),
231        }
232    }
233
234    /// Attach the language-server engine. Its presence is the toggle: every
235    /// mutating operation (write/edit/rollback) then reports passive LSP
236    /// diagnostics on the touched files, and reads warm the servers up.
237    #[must_use]
238    pub fn with_lsp(mut self, lsp: Arc<Lsp>) -> Self {
239        self.lsp = Some(lsp);
240        self
241    }
242
243    /// Suppress passive LSP feedback for the chained call only — the `Fs`
244    /// keeps its default behavior for later calls.
245    #[must_use]
246    pub fn without_lsp(&self) -> FsCall<'_> {
247        FsCall {
248            fs: self,
249            lsp: None,
250            include_warnings: false,
251        }
252    }
253
254    /// Include warnings (not just errors) in the chained call's passive LSP
255    /// feedback only — the `Fs` keeps its default behavior for later calls.
256    #[must_use]
257    pub fn warnings(&self) -> FsCall<'_> {
258        FsCall {
259            fs: self,
260            lsp: self.lsp.as_ref(),
261            include_warnings: true,
262        }
263    }
264
265    /// Set the project root directory (used for path-validation guards).
266    #[must_use]
267    pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
268        self.root = path.into();
269        self
270    }
271
272    /// Set the explicit write-path allowlist.
273    #[must_use]
274    pub fn allowlist(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
275        self.allowlist = Some(paths.into_iter().map(Into::into).collect());
276        self
277    }
278
279    /// Set the explicit write-path blocklist.
280    #[must_use]
281    pub fn blocklist(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
282        self.blocklist = Some(paths.into_iter().map(Into::into).collect());
283        self
284    }
285
286    /// Set the read-only path allowlist (falls back to [`allowlist`](Self::allowlist) when `None`).
287    #[must_use]
288    pub fn read_allowlist(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
289        self.read_allowlist = Some(paths.into_iter().map(Into::into).collect());
290        self
291    }
292
293    /// Set the read-only path blocklist (falls back to [`blocklist`](Self::blocklist) when `None`).
294    #[must_use]
295    pub fn read_blocklist(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
296        self.read_blocklist = Some(paths.into_iter().map(Into::into).collect());
297        self
298    }
299
300    /// Build a write-scope [`FsMetadata`] from the current owned state.
301    fn metadata(&self) -> FsMetadata {
302        FsMetadata {
303            root: self.root.clone(),
304            allowlist: self.allowlist.clone(),
305            blocklist: self.blocklist.clone(),
306        }
307    }
308
309    /// Build a read-scope [`FsMetadata`] from the current owned state.
310    ///
311    /// Uses `read_allowlist`/`read_blocklist` when set, falling back to
312    /// the write-scope guards.
313    fn read_metadata(&self) -> FsMetadata {
314        FsMetadata {
315            root: self.root.clone(),
316            allowlist: self
317                .read_allowlist
318                .clone()
319                .or_else(|| self.allowlist.clone()),
320            blocklist: self
321                .read_blocklist
322                .clone()
323                .or_else(|| self.blocklist.clone()),
324        }
325    }
326
327    /// Read one or more files / symbols.
328    ///
329    /// See [`read`] for details. With [`Fs::with_lsp`] configured, each read
330    /// warms the file's language servers up (no diagnostics attached).
331    pub async fn read(&self, targets: Vec<Target>) -> Vec<ReadResult> {
332        self.read_op(self.lsp.as_ref(), targets).await
333    }
334
335    async fn read_op(&self, lsp: Option<&Arc<Lsp>>, targets: Vec<Target>) -> Vec<ReadResult> {
336        if let Some(lsp) = lsp {
337            for t in &targets {
338                Self::warm_lsp(lsp, &self.root, &t.path).await;
339            }
340        }
341        read(self.read_metadata(), FsRead { targets }).await
342    }
343
344    /// Write content to one or more files.
345    ///
346    /// See [`write`] for details.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if the allowlist/blocklist configuration is invalid.
351    pub async fn write(&self, targets: Vec<TargetFile>) -> Result<Vec<WriteResult>, String> {
352        self.write_op(self.lsp.as_ref(), false, targets).await
353    }
354
355    async fn write_op(
356        &self,
357        lsp: Option<&Arc<Lsp>>,
358        include_warnings: bool,
359        targets: Vec<TargetFile>,
360    ) -> Result<Vec<WriteResult>, String> {
361        let mut results = write(self.metadata(), FsWrite { targets }).await?;
362        if let Some(lsp) = lsp {
363            for result in &mut results {
364                result.lsp_notes =
365                    Self::passive_notes(lsp, &self.root, &result.path, include_warnings).await;
366            }
367        }
368        Ok(results)
369    }
370
371    /// Apply content-anchored replacements (the `fs_edit` wrapper over the
372    /// content replace engine). Each edit replaces an exact,
373    /// uniquely-matching `old_string` with `new_string`, anchored on the
374    /// file's content-hash snapshot tag.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error when a tag is missing/unknown, `old_string` does not
379    /// match exactly or is ambiguous, or the underlying edit fails.
380    pub async fn edit(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
381        self.edit_op(self.lsp.as_ref(), false, args).await
382    }
383
384    /// Edit lines from the `fs_edit_lines` wrapper over the hashline replace
385    /// engine (the legacy `targets`/`edit` core entry point).
386    ///
387    /// Accepts the advertised flat `{path, file_hash, ops}` object or the
388    /// legacy `{targets: [...]}` batch form (kept for benchmarks; not
389    /// advertised by the schema).
390    ///
391    /// # Errors
392    ///
393    /// Returns an error when the arguments do not match either accepted
394    /// shape, or when the engine fails.
395    pub async fn edit_lines(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
396        self.edit_lines_op(self.lsp.as_ref(), false, args).await
397    }
398
399    /// Structural syntax-tree rewrite from the `fs_edit_ast` wrapper over the
400    /// AST engine. Accepts the advertised flat `{path, ops: [{pat, out}]}`
401    /// object or the legacy `{ast: {...}}` / `{paths: [...]}` batch forms
402    /// (kept for codemods/benchmarks; not advertised by the schema).
403    ///
404    /// # Errors
405    ///
406    /// Returns an error when the arguments do not match either accepted
407    /// shape, or when the engine fails.
408    pub async fn edit_ast(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
409        self.edit_ast_op(self.lsp.as_ref(), false, args).await
410    }
411
412    async fn edit_op(
413        &self,
414        lsp: Option<&Arc<Lsp>>,
415        include_warnings: bool,
416        args: serde_json::Value,
417    ) -> Result<Vec<EditResult>, String> {
418        let metadata = self.metadata();
419        let mut results = self.edit_content(&metadata, &args).await?;
420        Self::attach_edit_notes(self, lsp, include_warnings, &mut results).await;
421        Ok(results)
422    }
423
424    async fn edit_lines_op(
425        &self,
426        lsp: Option<&Arc<Lsp>>,
427        include_warnings: bool,
428        args: serde_json::Value,
429    ) -> Result<Vec<EditResult>, String> {
430        let metadata = self.metadata();
431        let mut results = self.edit_replace(&metadata, &args).await?;
432        Self::attach_edit_notes(self, lsp, include_warnings, &mut results).await;
433        Ok(results)
434    }
435
436    async fn edit_ast_op(
437        &self,
438        lsp: Option<&Arc<Lsp>>,
439        include_warnings: bool,
440        args: serde_json::Value,
441    ) -> Result<Vec<EditResult>, String> {
442        let metadata = self.metadata();
443        let mut results = self.edit_ast_engine(&metadata, &args).await?;
444        Self::attach_edit_notes(self, lsp, include_warnings, &mut results).await;
445        Ok(results)
446    }
447
448    /// Shared post-edit passive LSP feedback. Previews never wrote anything:
449    /// the diagnostics would describe the UNCHANGED on-disk file, not the edit.
450    async fn attach_edit_notes(
451        &self,
452        lsp: Option<&Arc<Lsp>>,
453        include_warnings: bool,
454        results: &mut [EditResult],
455    ) {
456        let Some(lsp) = lsp else { return };
457        for result in results {
458            if result.dry_run == Some(true) {
459                continue;
460            }
461            result.lsp_notes =
462                Self::passive_notes(lsp, &self.root, &result.path, include_warnings).await;
463        }
464    }
465
466    /// Apply content-anchored replacements (the typed form of the `edits`
467    /// argument of the legacy batch API). Each edit replaces an exact,
468    /// uniquely-matching `old_string` with `new_string`, anchored on the
469    /// file's content-hash snapshot tag.
470    ///
471    /// # Errors
472    ///
473    /// Returns an error when a tag is missing/unknown, `old_string` does not
474    /// match exactly or is ambiguous, or the underlying edit fails.
475    pub async fn replace_edits(&self, edits: Vec<ReplaceEdit>) -> Result<Vec<EditResult>, String> {
476        self.replace_edits_op(self.lsp.as_ref(), false, edits).await
477    }
478
479    async fn replace_edits_op(
480        &self,
481        lsp: Option<&Arc<Lsp>>,
482        include_warnings: bool,
483        edits: Vec<ReplaceEdit>,
484    ) -> Result<Vec<EditResult>, String> {
485        let metadata = self.metadata();
486        let mut results = replace::content_edit(&metadata, &edits, false)
487            .await
488            .map_err(|e| e.to_string())?;
489        if let Some(lsp) = lsp {
490            for result in &mut results {
491                result.lsp_notes =
492                    Self::passive_notes(lsp, &self.root, &result.path, include_warnings).await;
493            }
494        }
495        Ok(results)
496    }
497
498    /// MCP `fs_edit_lines` description (the hashline replace engine).
499    ///
500    /// Dedicated tool for line/block edits: the model never has to pick an
501    /// engine by argument shape — the tool name selects it.
502    fn description_edit_lines() -> ToolDescription {
503        serde_json::json!({
504            "name": "fs_edit_lines",
505            "description": concat!(
506                "Apply targeted line/block edits to ONE file per call. Edits are ",
507                "anchored by the file's content hash for safety. ",
508                "Supports replace, delete, insert (before/after/head/tail), and ",
509                "syntactic block operations. A successful edit returns the ",
510                "updated \u{00B6}path#TAG header — use it directly for follow-up ",
511                "edits on the same file without re-reading. ",
512                "Example: {\"path\": \"src/main.rs\", \"file_hash\": \"3C4D\", ",
513                "\"ops\": \"replace 5..7:\\n+fn hello() {\\n+    println!(\\\"hi\\\");\\n+}\"}"
514            ),
515            "inputSchema": {
516                "type": "object",
517                "properties": {
518                    "path": {
519                        "type": "string",
520                        "description": "Path to the file to edit, relative to the project root"
521                    },
522                    "file_hash": {
523                        "type": "string",
524                        "description": concat!(
525                            "4-hex content hash tag: the \u{00B6}path#TAG anchor. ",
526                            "Get it from your last fs_read OR from a previous ",
527                            "fs_edit result (the `header` field carries the ",
528                            "updated tag). Copy verbatim."
529                        )
530                    },
531                    "ops": {
532                        "type": "string",
533                        "description": concat!(
534                            "Hashline edit operations. Each operation is on its own line:\n",
535                            "- replace N..M:  replace lines N through M with new content\n",
536                            "   (prefix each replacement line with +)\n",
537                            "- delete N..M   delete lines N through M\n",
538                            "- insert before N:  insert lines before line N\n",
539                            "- insert after N:   insert lines after line N\n",
540                            "- insert head:      insert at start of file\n",
541                            "- insert tail:      insert at end of file\n",
542                            "- replace block N:  replace syntactic block at line N\n",
543                            "Example:\n",
544                            "  replace 5..7:\n",
545                            "  +fn hello() {\n",
546                            "  +    println!(\"hi\");\n",
547                            "  +}\n",
548                            "  delete 10..12\n",
549                            "  insert after 15:\n",
550                            "  +// new comment"
551                        )
552                    },
553                    "dry_run": {
554                        "type": "boolean",
555                        "description": concat!(
556                            "Preview mode: the edit is applied in memory only and the ",
557                            "result carries the unified diff plus the syntax-probe ",
558                            "verdict without writing anything. Re-issue without dry_run ",
559                            "to apply the exact same edit."
560                        )
561                    }
562                },
563                "required": ["path", "file_hash", "ops"]
564            }
565        })
566    }
567
568    /// MCP `fs_ast_edit` description (the AST structural engine).
569    fn description_edit_ast() -> ToolDescription {
570        serde_json::json!({
571            "name": "fs_ast_edit",
572            "description": concat!(
573                "Apply structural syntax-tree edits to ONE file per call. ",
574                "Rewrite each match of a pattern (`pat`) to a template (`out`). ",
575                "Patterns must be full, structurally valid source snippets; they support ",
576                "metavariables $NAME (matches exactly one node) and $$$NAME (matches zero or ",
577                "more nodes, e.g. an argument list), and the same metavariable must capture ",
578                "identical text in every occurrence to match. A string literal pattern must ",
579                "include its quotes, and a metavariable captures a whole node — it does not ",
580                "match a substring inside a literal. So to change the text of a string, match ",
581                "the whole literal. ",
582                "Example: {\"path\": \"src/app.js\", \"ops\": [{\"pat\": ",
583                "\"console.log(\\\"$M\\\")\", \"out\": \"console.log([\\\"$M\\'])\"}]}",
584                " A successful edit returns the updated \u{00B6}path#TAG header — ",
585                "use it directly for follow-up edits on the same file without ",
586                "re-reading."
587            ),
588            "inputSchema": {
589                "type": "object",
590                "properties": {
591                    "path": {
592                        "type": "string",
593                        "description": "Path to the file to edit, relative to the project root"
594                    },
595                    "ops": {
596                        "type": "array",
597                        "description": "Rewrite ops applied in order",
598                        "items": {
599                            "type": "object",
600                            "properties": {
601                                "pat": {
602                                    "type": "string",
603                                    "description": concat!(
604                                        "Complete, structurally valid AST pattern. ",
605                                        "Supports metavariables $NAME (exactly one node) ",
606                                        "and $$$NAME (zero or more nodes, e.g. an argument ",
607                                        "list); the same metavariable must capture identical ",
608                                        "text. Include quotes around string literals; a ",
609                                        "metavariable matches a whole node, never a substring ",
610                                        "inside a literal."
611                                    )
612                                },
613                                "out": {
614                                    "type": "string",
615                                    "description": "Replacement template; captured metavariables may be referenced"
616                                }
617                            },
618                            "required": ["pat", "out"]
619                        }
620                    }
621                },
622                "required": ["path", "ops"]
623            }
624        })
625    }
626
627    /// MCP `fs_edit` description (the content replace engine).
628    ///
629    /// Follows the Claude Code `Edit` shape the models train on massively
630    /// (`{file_path?, old_string, new_string, replace_all?}`), adapted to this
631    /// environment's mandatory `¶path#TAG` hashline anchor (`path`/`file_hash`).
632    fn description_edit() -> ToolDescription {
633        serde_json::json!({
634            "name": "fs_edit",
635            "description": concat!(
636                "Performs exact string replacement in a file: replaces an ",
637                "exact `old_string` (which must occur exactly once, unless ",
638                "`replace_all`) with `new_string`. Use it when the text ",
639                "itself identifies the location, with the smallest unique ",
640                "snippet. Copy `old_string` verbatim including whitespace ",
641                "and newlines. A successful edit returns the updated ",
642                "\u{00B6}path#TAG header — use it directly for follow-up edits ",
643                "on the same file without re-reading. ",
644                "Example: {\"path\": \"src/main.rs\", \"file_hash\": \"3C4D\", ",
645                "\"old_string\": \"old_computation(x)\", \"new_string\": ",
646                "\"new_computation(x)\"}"
647            ),
648            "inputSchema": {
649                "type": "object",
650                "properties": {
651                    "path": {
652                        "type": "string",
653                        "description": "Path to the file to edit, relative to the project root"
654                    },
655                    "file_hash": {
656                        "type": "string",
657                        "description": concat!(
658                            "4-hex content hash tag: the \u{00B6}path#TAG anchor ",
659                            "from your last read of this file (or a previous ",
660                            "edit result's `header`). Copy verbatim. Optional ",
661                            "for the first edit of the call: follow-up edits ",
662                            "to the same file chain on the fresh tag produced ",
663                            "by the previous edit."
664                        )
665                    },
666                    "old_string": {
667                        "type": "string",
668                        "description": "The exact text to replace"
669                    },
670                    "new_string": {
671                        "type": "string",
672                        "description": concat!(
673                            "The text to replace it with (empty deletes the ",
674                            "matched text)"
675                        )
676                    },
677                    "replace_all": {
678                        "type": "boolean",
679                        "description": concat!(
680                            "Replace all occurrences of old_string ",
681                            "(default false)"
682                        )
683                    },
684                    "dry_run": {
685                        "type": "boolean",
686                        "description": concat!(
687                            "Preview mode: the edit is applied in memory only and the ",
688                            "result carries the unified diff plus the syntax-probe ",
689                            "verdict (a warning when the edit would break parsing) ",
690                            "without writing anything. Re-issue without dry_run to ",
691                            "apply the exact same edit."
692                        )
693                    }
694                },
695                "required": ["path", "old_string", "new_string"]
696            }
697        })
698    }
699
700    async fn edit_replace(
701        &self,
702        metadata: &FsMetadata,
703        args: &serde_json::Value,
704    ) -> Result<Vec<EditResult>, String> {
705        let dry_run = args
706            .get("dry_run")
707            .and_then(serde_json::Value::as_bool)
708            .unwrap_or(false);
709        let targets_value = if let Some(t) = args.get("targets") {
710            // Batch form: kept for benchmarks, not advertised by the schema.
711            t.clone()
712        } else if args.get("path").is_some() && args.get("ops").is_some() {
713            // Advertised single-path shape: a flat {path, file_hash, ops}.
714            serde_json::Value::Array(vec![args.clone()])
715        } else {
716            return Err(
717                "the replace engine requires {path, file_hash, ops} — one file per call"
718                    .to_string(),
719            );
720        };
721        let targets: Vec<EditTarget> = serde_json::from_value(targets_value)
722            .map_err(|e| format!("invalid `path`/`file_hash`/`ops` for the replace engine: {e}"))?;
723        edit(metadata.clone(), FsEdit { targets, dry_run })
724            .await
725            .map_err(|e| e.to_string())
726    }
727
728    /// AST-engine adapter (named distinctly from the public
729    /// [`Fs::edit_ast`](Self::edit_ast) wrapper).
730    async fn edit_ast_engine(
731        &self,
732        metadata: &FsMetadata,
733        args: &serde_json::Value,
734    ) -> Result<Vec<EditResult>, String> {
735        // Advertised single-path shape: a flat {ops: [...], path}. The
736        // multi-path `paths` array form is kept for codemods/benchmarks but
737        // not advertised by the schema.
738        let ast_value = if let Some(ast) = args.get("ast") {
739            ast.clone()
740        } else if args.get("ops").is_some_and(|v| v.is_array()) {
741            let mut flat = args.clone();
742            if let Some(path) = flat.get("path").cloned()
743                && let Some(obj) = flat.as_object_mut()
744            {
745                obj.insert("paths".into(), serde_json::json!([path]));
746            }
747            flat
748        } else {
749            return Err(
750                "the AST engine requires {path, ops: [{pat, out}]} — one file per call".to_string(),
751            );
752        };
753        let fs_ast: FsAstEdit = serde_json::from_value(ast_value)
754            .map_err(|e| format!("invalid `ops`/`path` arguments for the AST engine: {e}"))?;
755        crate::fs::ast_edit::ast_edit(metadata.clone(), fs_ast).await
756    }
757
758    async fn edit_content(
759        &self,
760        metadata: &FsMetadata,
761        args: &serde_json::Value,
762    ) -> Result<Vec<EditResult>, String> {
763        // Advertised single-edit shape: a flat {path, file_hash?, old_string,
764        // new_string, replace_all?}. The array/batch form is kept for
765        // benchmarks but not advertised by the schema.
766        let edits_value = if let Some(arr) = args.get("edits").filter(|v| v.is_array()) {
767            arr.clone()
768        } else if args.get("old_string").is_some() {
769            serde_json::Value::Array(vec![args.clone()])
770        } else {
771            return Err(
772                "the content replace engine requires {path, file_hash?, old_string, \
773                 new_string, replace_all?} — one file per call"
774                    .to_string(),
775            );
776        };
777        let dry_run = args
778            .get("dry_run")
779            .and_then(serde_json::Value::as_bool)
780            .unwrap_or(false);
781        let fs_edits: FsContentEdit =
782            serde_json::from_value(serde_json::json!({ "edits": edits_value, "dry_run": dry_run }))
783                .map_err(|e| {
784                    format!("invalid edit arguments for the content replace engine: {e}")
785                })?;
786        replace::content_edit(metadata, &fs_edits.edits, fs_edits.dry_run)
787            .await
788            .map_err(|e| e.to_string())
789    }
790
791    /// Roll back a file to a previously recorded session version.
792    ///
793    /// See [`rollback`] for details.
794    ///
795    /// # Errors
796    ///
797    /// Returns an error if write permission is denied or the path has no
798    /// rollback history.
799    pub async fn rollback(&self, path: &str, hash: &str) -> Result<RollbackResult, String> {
800        self.rollback_op(self.lsp.as_ref(), false, path, hash).await
801    }
802
803    async fn rollback_op(
804        &self,
805        lsp: Option<&Arc<Lsp>>,
806        include_warnings: bool,
807        path: &str,
808        hash: &str,
809    ) -> Result<RollbackResult, String> {
810        let mut result = rollback(&FsRollback, self.metadata(), path, hash).await?;
811        if let Some(lsp) = lsp {
812            result.lsp_notes =
813                Self::passive_notes(lsp, &self.root, &result.path, include_warnings).await;
814        }
815        Ok(result)
816    }
817
818    /// Ensure the servers covering `rel` are running and the file is open on
819    /// them. Passive best-effort: failures are logged, never surfaced.
820    async fn warm_lsp(lsp: &Lsp, root: &std::path::Path, rel: &str) {
821        let path = root.join(rel);
822        if let Ok(handles) = lsp.manager().ensure_for_file(&path).await {
823            for handle in &handles {
824                if let Err(err) = handle.touch_file(&path).await {
825                    log::debug!("lsp warm-up of `{}` failed: {err}", path.display());
826                }
827            }
828        }
829    }
830
831    /// Collect structured diagnostics for `rel` after a mutation: ensure the
832    /// file's servers are up, wait for the diagnostics to settle, then split
833    /// the snapshot by severity. Passive best-effort — any failure degrades
834    /// to `None` so the tool result is never rejected because of LSP.
835    async fn passive_notes(
836        lsp: &Lsp,
837        root: &std::path::Path,
838        rel: &str,
839        include_warnings: bool,
840    ) -> Option<LspNotes> {
841        let path = root.join(rel);
842        let display = std::path::Path::new(rel)
843            .strip_prefix(root)
844            .unwrap_or(std::path::Path::new(rel))
845            .display()
846            .to_string();
847
848        let Ok(handles) = lsp.manager().ensure_for_file(&path).await else {
849            return None;
850        };
851        for handle in &handles {
852            if let Err(err) = handle.touch_file(&path).await {
853                log::debug!(
854                    "passive diagnostics: could not open `{}` on {}: {err}",
855                    path.display(),
856                    handle.name()
857                );
858            }
859            // The fs layer just wrote the file to disk: signal the save so
860            // servers with post-save pipelines (rust-analyzer's flycheck)
861            // regenerate compile-error diagnostics — didChange alone never
862            // triggers them, and the server's own watcher may be broken.
863            if let Err(err) = handle.save_file(&path) {
864                log::debug!(
865                    "passive diagnostics: could not save `{}` on {}: {err}",
866                    path.display(),
867                    handle.name()
868                );
869            }
870        }
871
872        let engine = lsp.diagnostics_engine();
873
874        // Pull the diagnostics instead of waiting for a push. Servers that
875        // see the client advertising the `diagnostic` capability — this one
876        // does — may never push (rust-analyzer answers with
877        // `workspace/diagnostic/refresh` nudges and goes silent), so the
878        // only reliable observation after a mutation is an explicit pull.
879        //
880        // A server mid-analysis answers `-32802 ServerCancelled`: per the
881        // pull contract that means "retry", so each server keeps re-pulling
882        // until it delivers, until the shared budget expires, or until it
883        // reports a definitive failure. Servers without pull support answer
884        // `MethodNotFound` and the push stream remains the source; both
885        // paths feed the same engine.
886        let pull_deadline = std::time::Instant::now() + LSP_REACTION;
887        for handle in &handles {
888            loop {
889                match handle
890                    .pull_diagnostics(&path, Duration::from_secs(LSP_PULL_TIMEOUT_SECS))
891                    .await
892                {
893                    Ok(Some(params)) => {
894                        // Ingest under a pull-scoped source: the same server
895                        // also pushes flycheck diagnostics (cargo check
896                        // errors), and the store replaces per source — a
897                        // bare-source ingest would let analysis hints wipe
898                        // freshly published compile errors.
899                        let pull_source = format!("{}/pull", handle.name());
900                        engine.ingest(&pull_source, &params);
901                        break;
902                    }
903                    Ok(None) => break,
904                    Err(cosh_sdk::lsp::LspError::Rpc { code: -32802, .. })
905                        if std::time::Instant::now() < pull_deadline =>
906                    {
907                        tokio::time::sleep(Duration::from_millis(300)).await;
908                    }
909                    Err(err) => {
910                        log::debug!(
911                            "passive diagnostics: pull from {} failed: {err}",
912                            handle.name()
913                        );
914                        break;
915                    }
916                }
917            }
918        }
919
920        // Give any push-driven burst a quiet window before snapshotting.
921        engine.wait_for_settle(LSP_SETTLE).await;
922
923        let mut notes = LspNotes::default();
924        for diag in engine.snapshot_for(&path) {
925            let note = LspNote {
926                path: display.clone(),
927                line: diag.range.start.line + 1,
928                message: diag.message,
929                source: diag.source,
930            };
931            if diag.severity.is_none_or(|s| s <= DiagnosticSeverity::ERROR) {
932                if notes.errors.len() < LSP_MAX_NOTES {
933                    notes.errors.push(note);
934                }
935            } else if diag.severity == Some(DiagnosticSeverity::WARNING)
936                && include_warnings
937                && notes.warnings.len() < LSP_MAX_NOTES
938            {
939                notes.warnings.push(note);
940            }
941        }
942
943        (!notes.errors.is_empty() || !notes.warnings.is_empty()).then_some(notes)
944    }
945
946    /// Get the project root path.
947    #[must_use]
948    pub const fn root(&self) -> &PathBuf {
949        &self.root
950    }
951
952    /// Get the write allowlist (read-only reference).
953    #[must_use]
954    pub fn allowlist_ref(&self) -> Option<&[PathBuf]> {
955        self.allowlist.as_deref()
956    }
957
958    /// Get the write blocklist (read-only reference).
959    #[must_use]
960    pub fn blocklist_ref(&self) -> Option<&[PathBuf]> {
961        self.blocklist.as_deref()
962    }
963
964    /// Add a path to the write allowlist.
965    ///
966    /// If the allowlist is `None`, it is created. Duplicate paths are ignored.
967    pub fn add_allowlist_path(&mut self, path: PathBuf) {
968        let list = self.allowlist.get_or_insert_with(Vec::new);
969        if !list.contains(&path) {
970            list.push(path);
971        }
972    }
973
974    /// Remove a path from the write allowlist (for AllowOnce cleanup).
975    ///
976    /// If the path is not in the allowlist, this is a no-op.
977    pub fn remove_allowlist_path(&mut self, path: &std::path::Path) {
978        if let Some(list) = self.allowlist.as_mut() {
979            list.retain(|p| p.as_path() != path);
980        }
981        if let Some(list) = self.read_allowlist.as_mut() {
982            list.retain(|p| p.as_path() != path);
983        }
984    }
985}
986
987// Auto-dispatch schema-misuse detection
988
989/// Per-call policy guard returned by [`Fs::without_lsp`] and [`Fs::warnings`].
990///
991/// The guard carries the policy for exactly the calls made through it; the
992/// originating `Fs` keeps its default behavior for every later call.
993pub struct FsCall<'a> {
994    fs: &'a Fs,
995    lsp: Option<&'a Arc<Lsp>>,
996    include_warnings: bool,
997}
998
999impl FsCall<'_> {
1000    /// See [`Fs::read`].
1001    pub async fn read(&self, targets: Vec<Target>) -> Vec<ReadResult> {
1002        self.fs.read_op(self.lsp, targets).await
1003    }
1004
1005    /// See [`Fs::write`].
1006    ///
1007    /// # Errors
1008    ///
1009    /// Same conditions as [`Fs::write`].
1010    pub async fn write(&self, targets: Vec<TargetFile>) -> Result<Vec<WriteResult>, String> {
1011        self.fs
1012            .write_op(self.lsp, self.include_warnings, targets)
1013            .await
1014    }
1015
1016    /// See [`Fs::edit`].
1017    ///
1018    /// # Errors
1019    ///
1020    /// Same conditions as [`Fs::edit`].
1021    pub async fn edit(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
1022        self.fs.edit_op(self.lsp, self.include_warnings, args).await
1023    }
1024
1025    /// See [`Fs::edit_lines`].
1026    ///
1027    /// # Errors
1028    ///
1029    /// Same conditions as [`Fs::edit_lines`].
1030    pub async fn edit_lines(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
1031        self.fs
1032            .edit_lines_op(self.lsp, self.include_warnings, args)
1033            .await
1034    }
1035
1036    /// See [`Fs::edit_ast`].
1037    ///
1038    /// # Errors
1039    ///
1040    /// Same conditions as [`Fs::edit_ast`].
1041    pub async fn edit_ast(&self, args: serde_json::Value) -> Result<Vec<EditResult>, String> {
1042        self.fs
1043            .edit_ast_op(self.lsp, self.include_warnings, args)
1044            .await
1045    }
1046
1047    /// See [`Fs::rollback`].
1048    ///
1049    /// # Errors
1050    ///
1051    /// Same conditions as [`Fs::rollback`].
1052    pub async fn rollback(&self, path: &str, hash: &str) -> Result<RollbackResult, String> {
1053        self.fs
1054            .rollback_op(self.lsp, self.include_warnings, path, hash)
1055            .await
1056    }
1057}