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