Skip to main content

aft/commands/
write.rs

1//! Handler for the `write` command: full file write with auto-backup.
2
3use std::path::Path;
4
5use lsp_types::FileChangeType;
6
7use crate::context::AppContext;
8use crate::edit;
9use crate::protocol::{RawRequest, Response};
10
11/// Handle a `write` request.
12///
13/// Params:
14///   - `file` (string, required) — target file path
15///   - `content` (string, required) — content to write
16///   - `create_dirs` (bool, optional, default true) — create parent dirs if missing
17///
18/// Returns: `{ file, created, syntax_valid?, backup_id? }`
19pub fn handle_write(req: &RawRequest, ctx: &AppContext) -> Response {
20    let op_id = crate::backup::new_op_id();
21    let file = match req.params.get("file").and_then(|v| v.as_str()) {
22        Some(f) => f,
23        None => {
24            return Response::error(
25                &req.id,
26                "invalid_request",
27                "write: missing required param 'file'",
28            );
29        }
30    };
31
32    let content = match req.params.get("content").and_then(|v| v.as_str()) {
33        Some(c) => c,
34        None => {
35            return Response::error(
36                &req.id,
37                "invalid_request",
38                "write: missing required param 'content'",
39            );
40        }
41    };
42
43    if super::github_comments::is_github_resource_path(file) {
44        return super::github_comments::handle_comment_write(req, ctx, file, content);
45    }
46
47    let create_dirs = req
48        .params
49        .get("create_dirs")
50        .and_then(|v| v.as_bool())
51        .unwrap_or(true);
52
53    let path = match ctx.validate_path(&req.id, Path::new(file)) {
54        Ok(path) => path,
55        Err(resp) => return resp,
56    };
57    let existed = path.exists();
58
59    // Capture pre-write content when the file exists so we can detect no-op
60    // writes (file content byte-identical to original) and emit honest
61    // `no_op: true` for UIs. Used by diff metadata too when requested.
62    // See GitHub #45.
63    let original = if existed {
64        match std::fs::read_to_string(path.as_path()) {
65            Ok(content) => content,
66            Err(error) => {
67                crate::slog_warn!(
68                    "write: failed to read existing file before diff for {}: {}",
69                    file,
70                    error
71                );
72                String::new()
73            }
74        }
75    } else {
76        String::new()
77    };
78
79    if edit::wants_preview(&req.params) {
80        let mut result = serde_json::json!({
81            "file": file,
82            "created": !existed,
83            "formatted": false,
84        });
85        if existed && original == content {
86            result["no_op"] = serde_json::json!(true);
87        }
88        edit::attach_preview_diff(&mut result, &req.params, file, &original, content);
89        return Response::success(&req.id, result);
90    }
91
92    // Auto-backup existing files before overwriting. For create-only writes,
93    // record a tombstone so operation undo removes the created file.
94    let backup_id = if existed {
95        match edit::auto_backup(
96            ctx,
97            req.session(),
98            path.as_path(),
99            "write: pre-write backup",
100            Some(&op_id),
101        ) {
102            Ok(id) => id,
103            Err(e) => {
104                return Response::error(&req.id, e.code(), e.to_string());
105            }
106        }
107    } else {
108        match ctx.backup().lock().snapshot_op_tombstone(
109            req.session(),
110            &op_id,
111            path.as_path(),
112            "write: file created by write",
113        ) {
114            Ok(id) => id,
115            Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
116        }
117    };
118
119    // Create parent directories if requested
120    if create_dirs {
121        if let Some(parent) = path.parent() {
122            if !parent.exists() {
123                if let Err(e) = std::fs::create_dir_all(parent) {
124                    if !existed {
125                        ctx.backup()
126                            .lock()
127                            .discard_operation_entries(req.session(), &op_id);
128                    }
129                    return Response::error(
130                        &req.id,
131                        "invalid_request",
132                        format!("write: failed to create directories: {}", e),
133                    );
134                }
135            }
136        }
137    }
138
139    // Write, format, and validate via shared pipeline
140    let mut write_result =
141        match edit::write_format_validate(path.as_path(), content, &ctx.config(), &req.params) {
142            Ok(r) => r,
143            Err(e) => {
144                if !existed {
145                    ctx.backup()
146                        .lock()
147                        .discard_operation_entries(req.session(), &op_id);
148                }
149                return Response::error(&req.id, e.code(), e.to_string());
150            }
151        };
152
153    if write_result.rolled_back {
154        ctx.backup()
155            .lock()
156            .discard_operation_entries(req.session(), &op_id);
157    }
158
159    if let Ok(final_content) = std::fs::read_to_string(path.as_path()) {
160        let config_change_type = if existed {
161            FileChangeType::CHANGED
162        } else {
163            FileChangeType::CREATED
164        };
165        ctx.lsp_notify_watched_config_file(path.as_path(), config_change_type);
166        write_result.lsp_outcome = ctx.lsp_post_write(path.as_path(), &final_content, &req.params);
167    }
168
169    log::debug!("write: {}", file);
170
171    let mut result = serde_json::json!({
172        "file": file,
173        "created": !existed,
174        "formatted": write_result.formatted,
175    });
176
177    if let Some(valid) = write_result.syntax_valid {
178        result["syntax_valid"] = serde_json::json!(valid);
179    }
180
181    if let Some(ref reason) = write_result.format_skipped_reason {
182        result["format_skipped_reason"] = serde_json::json!(reason);
183    }
184
185    if write_result.validate_requested {
186        result["validation_errors"] = serde_json::json!(write_result.validation_errors);
187    }
188    if let Some(ref reason) = write_result.validate_skipped_reason {
189        result["validate_skipped_reason"] = serde_json::json!(reason);
190    }
191
192    if let Some(ref id) = backup_id {
193        result["backup_id"] = serde_json::json!(id);
194    }
195    edit::attach_backup_skipped_reason(
196        &mut result,
197        ctx,
198        req.session(),
199        &op_id,
200        Some(path.as_path()),
201    );
202
203    write_result.append_lsp_diagnostics_to(&mut result);
204    write_result.append_reformatted_excerpt_to(&mut result);
205
206    // Read final on-disk content once for no_op detection + diff metadata.
207    // Honest reporting: when the file existed AND the post-write content is
208    // byte-identical to `original`, surface `no_op: true` so UIs can render
209    // "wrote, but no net change" instead of a bare "File updated". See
210    // GitHub #45.
211    let final_content =
212        std::fs::read_to_string(path.as_path()).unwrap_or_else(|_| content.to_string());
213    if existed && original == final_content {
214        result["no_op"] = serde_json::json!(true);
215    }
216
217    if edit::wants_diff(&req.params) {
218        result["diff"] = edit::compute_diff_for_response(&req.params, &original, &final_content);
219    }
220
221    Response::success(&req.id, result)
222}