Skip to main content

cosh_tools/fs/
write.rs

1//! Overwrites one or more files completely.
2//!
3//! Verifies write-scope permissions using an [`FsMetadata`] structure that describes:
4//! - The project root directory.
5//! - Paths explicitly granted write permission.
6//! - Paths explicitly denied write permission.
7//!
8//! If no paths are explicitly blocked, any file within the root directory
9//! is considered writable by default.
10//!
11//! # Errors
12//!
13//! Returns `Err` if the [`FsMetadata`] has inconsistent allowlist/blocklist entries.
14//! Individual file write failures are reported inline in the returned string
15//! rather than aborting the batch.
16//!
17//! Content normalization is shared with [`read`](crate::fs::read) (LF + no BOM)
18//! and auto-generated files are refused via
19//! [`assert_editable_file`](crate::util::path_guard::assert_editable_file).
20use super::types::{FsMetadata, FsWrite};
21
22use crate::util::path_guard::{GuardResult, assert_editable_file, validate_path};
23use cosh_sdk::hashline::{
24    format,
25    fs::{DiskFilesystem, Filesystem},
26    normalize,
27};
28use cosh_sdk::rollback;
29use regex::Regex;
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32use std::path::Path;
33use std::sync::OnceLock;
34
35#[derive(Debug, Serialize, Deserialize, JsonSchema)]
36pub struct WriteResult {
37    pub path: String,
38    pub file_hash: String,
39    pub header: String,
40    pub warnings: Option<String>,
41    /// Passive LSP feedback collected after the write (errors by default,
42    /// warnings when the caller opted in). `None` when LSP is disabled or
43    /// nothing was found.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub lsp_notes: Option<super::types::LspNotes>,
46}
47
48/// Strip hashline display prefixes (`[path#hash]` headers and `N:` line prefixes)
49/// from content the model may have copied from read/search output.
50///
51/// The input must already be LF-normalized (no `\r`, no BOM) — the caller
52/// canonicalizes with [`normalize::normalize_to_lf`] first, mirroring `read`.
53#[allow(clippy::unwrap_used)]
54fn strip_write_content(content: &str) -> (String, bool) {
55    static BRACKET_HEADER_RE: OnceLock<Regex> = OnceLock::new();
56    let bracket_re =
57        BRACKET_HEADER_RE.get_or_init(|| Regex::new(r"^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$").unwrap());
58
59    let trimmed = content.strip_suffix('\n').unwrap_or(content);
60    let lines: Vec<String> = trimmed.split('\n').map(String::from).collect();
61
62    let stripped = cosh_sdk::hashline::prefixes::strip_new_line_prefixes(&lines);
63    if stripped != lines {
64        return (stripped.join("\n"), true);
65    }
66
67    let header_idx = lines.iter().position(|l| !l.trim().is_empty());
68    let Some(idx) = header_idx else {
69        return (content.to_string(), false);
70    };
71    if !bracket_re.is_match(&lines[idx]) {
72        return (content.to_string(), false);
73    }
74
75    let mut without_header = lines;
76    without_header.remove(idx);
77    let stripped2 = cosh_sdk::hashline::prefixes::strip_new_line_prefixes(&without_header);
78    if stripped2 != without_header {
79        return (stripped2.join("\n"), true);
80    }
81
82    (content.to_string(), false)
83}
84
85/// Make a file executable when its content starts with a `#!` shebang.
86/// Errors are swallowed — chmod failure must not abort a successful write.
87#[cfg(unix)]
88async fn maybe_make_executable(path: &str) -> bool {
89    use std::os::unix::fs::PermissionsExt;
90
91    let Ok(metadata) = tokio::fs::metadata(path).await else {
92        return false;
93    };
94    let mut perms = metadata.permissions();
95    let mode = perms.mode();
96    let new_mode = mode | 0o111;
97    if new_mode == mode {
98        return false;
99    }
100    perms.set_mode(new_mode);
101    tokio::fs::set_permissions(path, perms).await.is_ok()
102}
103
104#[cfg(not(unix))]
105async fn maybe_make_executable(_path: &str) -> bool {
106    false
107}
108
109/// Write content to one or more files.
110///
111/// # Errors
112///
113/// Returns `Err` if the [`FsMetadata`] has inconsistent allowlist/blocklist entries,
114/// or a path is both blocked and allowed simultaneously.
115pub async fn write(metadata: FsMetadata, tg: FsWrite) -> Result<Vec<WriteResult>, String> {
116    let mut result: Vec<WriteResult> = vec![];
117    let fs = DiskFilesystem::new();
118    for target in &tg.targets {
119        // Canonicalize to LF (and drop a UTF-8 BOM) exactly like `read` does, so
120        // the hash/header returned here matches what a follow-up read of the
121        // written file would report, and hashline prefix stripping sees one
122        // line-ending shape.
123        let normalized_input = normalize::normalize_to_lf(&normalize::strip_bom(&target.text).text);
124        let (clean_text, stripped) = strip_write_content(&normalized_input);
125
126        // An inconsistent configuration — a path in both the allowlist and
127        // the blocklist — is a hard error, not a per-file warning.
128        if let GuardResult::Mismatch(msg) = validate_path(
129            &target.path,
130            &metadata.root,
131            metadata.allowlist.as_deref(),
132            metadata.blocklist.as_deref(),
133        ) {
134            return Err(format!("permission denied: `{}` — {msg}", target.path));
135        }
136
137        if clean_text.trim().is_empty() {
138            let warning = if stripped {
139                format!(
140                    "text is empty for `{path}` after stripping hashline display prefixes.",
141                    path = target.path
142                )
143            } else {
144                format!(
145                    "text is empty for `{path}`. Nothing was sent to add to the file.",
146                    path = target.path
147                )
148            };
149            let res = WriteResult {
150                file_hash: String::new(),
151                header: String::new(),
152                path: target.path.clone(),
153                warnings: Some(warning),
154                lsp_notes: None,
155            };
156            result.push(res);
157            continue;
158        }
159
160        match metadata.fs_guard(&target.path) {
161            Ok(validated_path) => {
162                let path_str = validated_path.to_string_lossy();
163
164                // Never clobber a file that declares itself machine-generated:
165                // the change would be overwritten by the next generation run.
166                // Creating a brand-new file is always allowed (nothing is being
167                // overwritten). Per-file warning, not a batch abort.
168                if let Err(msg) = assert_editable_file(Path::new(path_str.as_ref())) {
169                    let res = WriteResult {
170                        file_hash: String::new(),
171                        header: String::new(),
172                        path: target.path.clone(),
173                        warnings: Some(msg),
174                        lsp_notes: None,
175                    };
176                    result.push(res);
177                    continue;
178                }
179
180                // Existing-file hash guard
181                // When the target already exists, the caller must supply a
182                // `file_hash` that matches the live content.  This proves
183                // the model has read the file before overwriting it.
184                let file_exists = fs.read_text(&path_str).await.is_ok();
185                if file_exists {
186                    match target.file_hash.as_deref() {
187                        None => {
188                            let res = WriteResult {
189                                file_hash: String::new(),
190                                header: String::new(),
191                                path: target.path.clone(),
192                                warnings: Some(format!(
193                                    "ERROR: Cannot overwrite `{}` without reading it first. \
194                                     The file already exists. To overwrite it you must: \
195                                     1) Use `fs_read` to read the file (this returns a `file_hash`). \
196                                     2) Include that `file_hash` in your `fs_write` call. \
197                                     This ensures you know the current content before overwriting it.",
198                                    target.path
199                                )),
200                                lsp_notes: None,
201                            };
202                            result.push(res);
203                            continue;
204                        }
205                        Some(expected_hash) => {
206                            if let Ok(current) = fs.read_text(&path_str).await {
207                                let actual_hash =
208                                    cosh_sdk::hashline::format::compute_file_hash(&current);
209                                if actual_hash != *expected_hash {
210                                    let res = WriteResult {
211                                        file_hash: String::new(),
212                                        header: String::new(),
213                                        path: target.path.clone(),
214                                        warnings: Some(format!(
215                                            "ERROR: file_hash mismatch for `{}`. \
216                                             The file has been modified since you last read it. \
217                                             The hash you provided ({}) does not match \
218                                             the current file content ({}). \
219                                             To fix this: 1) Use `fs_read` to read the file again. \
220                                             2) Use the new `file_hash` from the read result.",
221                                            target.path, expected_hash, actual_hash
222                                        )),
223                                        lsp_notes: None,
224                                    };
225                                    result.push(res);
226                                    continue;
227                                }
228                            }
229                        }
230                    }
231                }
232
233                if let Ok(current) = fs.read_text(&path_str).await {
234                    let _ = rollback::record(&path_str, &current);
235                }
236
237                if let Err(err) = fs.write_text(&path_str, &clean_text).await {
238                    let warning = format!("failed to write `{}`: {}", target.path, err);
239                    let res = WriteResult {
240                        file_hash: String::new(),
241                        header: String::new(),
242                        path: target.path.clone(),
243                        warnings: Some(warning),
244                        lsp_notes: None,
245                    };
246                    result.push(res);
247                    continue;
248                }
249
250                let made_executable =
251                    clean_text.starts_with("#!") && maybe_make_executable(&path_str).await;
252
253                cosh_sdk::tree_sitter::tree_sitter().invalidate(&path_str);
254                let _ = rollback::record(&path_str, &clean_text);
255
256                let hash = format::compute_file_hash(&clean_text);
257                let header = format::format_hashline_header(&path_str, &hash);
258
259                let mut warnings: Vec<String> = Vec::new();
260                if stripped {
261                    warnings.push(
262                        "auto-stripped hashline display prefixes from content before writing."
263                            .to_string(),
264                    );
265                }
266                if made_executable {
267                    warnings.push(
268                        "made executable via chmod +x (content starts with #! shebang)."
269                            .to_string(),
270                    );
271                }
272
273                let res = WriteResult {
274                    file_hash: hash,
275                    header,
276                    path: target.path.clone(),
277                    warnings: if warnings.is_empty() {
278                        None
279                    } else {
280                        Some(warnings.join("\n"))
281                    },
282                    lsp_notes: None,
283                };
284
285                result.push(res);
286            }
287            Err(e) => {
288                let warning = format!(
289                    "write permission denied for `{}`. \
290                     Files under `{}` are writable by default. \
291                     Use the allowlist to grant access to paths outside this directory. \
292                     ({})",
293                    target.path,
294                    metadata.root.display(),
295                    e,
296                );
297                let res = WriteResult {
298                    file_hash: String::new(),
299                    header: String::new(),
300                    path: target.path.clone(),
301                    warnings: Some(warning),
302                    lsp_notes: None,
303                };
304                result.push(res);
305            }
306        }
307    }
308    Ok(result)
309}