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    let create_dirs = req
44        .params
45        .get("create_dirs")
46        .and_then(|v| v.as_bool())
47        .unwrap_or(true);
48
49    let path = match ctx.validate_path(&req.id, Path::new(file)) {
50        Ok(path) => path,
51        Err(resp) => return resp,
52    };
53    let existed = path.exists();
54
55    // Capture pre-write content when the file exists so we can detect no-op
56    // writes (file content byte-identical to original) and emit honest
57    // `no_op: true` for UIs. Used by diff metadata too when requested.
58    // See GitHub #45.
59    let original = if existed {
60        match std::fs::read_to_string(path.as_path()) {
61            Ok(content) => content,
62            Err(error) => {
63                crate::slog_warn!(
64                    "write: failed to read existing file before diff for {}: {}",
65                    file,
66                    error
67                );
68                String::new()
69            }
70        }
71    } else {
72        String::new()
73    };
74
75    // Auto-backup existing files before overwriting. For create-only writes,
76    // record a tombstone so operation undo removes the created file.
77    let backup_id = if existed {
78        match edit::auto_backup(
79            ctx,
80            req.session(),
81            path.as_path(),
82            "write: pre-write backup",
83            Some(&op_id),
84        ) {
85            Ok(id) => id,
86            Err(e) => {
87                return Response::error(&req.id, e.code(), e.to_string());
88            }
89        }
90    } else {
91        match ctx.backup().borrow_mut().snapshot_op_tombstone(
92            req.session(),
93            &op_id,
94            path.as_path(),
95            "write: file created by write",
96        ) {
97            Ok(id) => Some(id),
98            Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
99        }
100    };
101
102    // Create parent directories if requested
103    if create_dirs {
104        if let Some(parent) = path.parent() {
105            if !parent.exists() {
106                if let Err(e) = std::fs::create_dir_all(parent) {
107                    if !existed {
108                        ctx.backup()
109                            .borrow_mut()
110                            .discard_operation_entries(req.session(), &op_id);
111                    }
112                    return Response::error(
113                        &req.id,
114                        "invalid_request",
115                        format!("write: failed to create directories: {}", e),
116                    );
117                }
118            }
119        }
120    }
121
122    // Write, format, and validate via shared pipeline
123    let mut write_result =
124        match edit::write_format_validate(path.as_path(), content, &ctx.config(), &req.params) {
125            Ok(r) => r,
126            Err(e) => {
127                if !existed {
128                    ctx.backup()
129                        .borrow_mut()
130                        .discard_operation_entries(req.session(), &op_id);
131                }
132                return Response::error(&req.id, e.code(), e.to_string());
133            }
134        };
135
136    if write_result.rolled_back {
137        ctx.backup()
138            .borrow_mut()
139            .discard_operation_entries(req.session(), &op_id);
140    }
141
142    if let Ok(final_content) = std::fs::read_to_string(path.as_path()) {
143        let config_change_type = if existed {
144            FileChangeType::CHANGED
145        } else {
146            FileChangeType::CREATED
147        };
148        ctx.lsp_notify_watched_config_file(path.as_path(), config_change_type);
149        write_result.lsp_outcome = ctx.lsp_post_write(path.as_path(), &final_content, &req.params);
150    }
151
152    log::debug!("write: {}", file);
153
154    let mut result = serde_json::json!({
155        "file": file,
156        "created": !existed,
157        "formatted": write_result.formatted,
158    });
159
160    if let Some(valid) = write_result.syntax_valid {
161        result["syntax_valid"] = serde_json::json!(valid);
162    }
163
164    if let Some(ref reason) = write_result.format_skipped_reason {
165        result["format_skipped_reason"] = serde_json::json!(reason);
166    }
167
168    if write_result.validate_requested {
169        result["validation_errors"] = serde_json::json!(write_result.validation_errors);
170    }
171    if let Some(ref reason) = write_result.validate_skipped_reason {
172        result["validate_skipped_reason"] = serde_json::json!(reason);
173    }
174
175    if let Some(ref id) = backup_id {
176        result["backup_id"] = serde_json::json!(id);
177    }
178
179    write_result.append_lsp_diagnostics_to(&mut result);
180
181    // Read final on-disk content once for no_op detection + diff metadata.
182    // Honest reporting: when the file existed AND the post-write content is
183    // byte-identical to `original`, surface `no_op: true` so UIs can render
184    // "wrote, but no net change" instead of a bare "File updated". See
185    // GitHub #45.
186    let final_content =
187        std::fs::read_to_string(path.as_path()).unwrap_or_else(|_| content.to_string());
188    if existed && original == final_content {
189        result["no_op"] = serde_json::json!(true);
190    }
191
192    if edit::wants_diff(&req.params) {
193        result["diff"] = edit::compute_diff_for_response(&req.params, &original, &final_content);
194    }
195
196    Response::success(&req.id, result)
197}