1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub lsp_notes: Option<super::types::LspNotes>,
46}
47
48#[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#[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
109pub 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 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 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 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 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(¤t);
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, ¤t);
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}