apimock_config/workspace/save.rs
1//! `Workspace::save()` and `has_unsaved_changes()`, plus the
2//! atomic-write helper they depend on.
3//!
4//! # Atomic write strategy
5//!
6//! `std::fs::write` is two syscalls (truncate + write); a concurrent
7//! reader can catch an empty file between them. We instead route every
8//! write through `tempfile::NamedTempFile::persist`, which writes the
9//! new contents to a sibling tempfile, syncs, and renames onto the
10//! destination. POSIX `rename(2)` is atomic at the directory-entry
11//! level; `tempfile` does the right thing on Windows via
12//! `MoveFileExW`. A reader either sees the old file or the new file —
13//! never a partial one.
14//!
15//! # Why diff is computed before refreshing baseline
16//!
17//! The diff summary describes "what this save just flushed to disk",
18//! computed against the previous baseline. We capture it before the
19//! baseline refresh in `save()` so there's still something to compare
20//! against; once `baseline_files` is updated to the freshly-written
21//! contents, the diff would always come back empty.
22
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26use crate::error::SaveError;
27use crate::view::SaveResult;
28
29use super::Workspace;
30
31impl Workspace {
32 /// Save the workspace back to disk.
33 ///
34 /// # Algorithm
35 ///
36 /// 1. Render each editable file (root + each rule set) to a
37 /// canonical TOML string *and* an editable-subset `Table`.
38 /// 2. Compare the canonical string against `baseline_files`. Files
39 /// whose canonical output is byte-identical to the baseline are
40 /// skipped entirely — nothing about them changed.
41 /// 3. For files that *do* differ: first confirm none of them
42 /// changed on disk since we last saw them (RFC 056 §2 Q3) —
43 /// checked for every file before any write, so a conflict on
44 /// one file can't leave another half-written.
45 /// 4. Mutate each file's own previous text in place
46 /// (`toml_writer::apply_in_place`) rather than rebuilding it, so
47 /// comments, blank lines and key order survive; only the values
48 /// that actually changed do. Write atomically via
49 /// `tempfile::NamedTempFile::persist` (same-directory rename(2)
50 /// on POSIX, `MoveFileExW` on Windows). On any single-file
51 /// write failure, the partial state is whatever rename(2)s have
52 /// already succeeded — see the type-level docstring on
53 /// `SaveError` for the rationale.
54 /// 5. After all writes succeed, refresh `baseline_files` (to the
55 /// canonical string) and `original_text` (to the just-written
56 /// text) so a subsequent save() and conflict check both compare
57 /// against what's now actually on disk.
58 /// 6. Compute `DiffItem`s by node, comparing the in-memory state
59 /// to the load-time baseline (parsed; not text-diff).
60 /// 7. Compute `requires_reload` / `requires_restart` from the set
61 /// of changed files: changes to `[listener]` need a restart,
62 /// everything else just a reload.
63 pub fn save(&mut self) -> Result<SaveResult, SaveError> {
64 // --- Render every file's canonical text + editable-subset
65 // target table -------------------------------------------------
66 let root_target = crate::toml_writer::root_table(&self.config);
67 let new_root_toml = crate::toml_writer::render_apimock_toml(&self.config);
68
69 let mut rule_set_renders: Vec<(PathBuf, toml::value::Table, String)> = Vec::new();
70 for rule_set in self.config.service.rule_sets.iter() {
71 let path = PathBuf::from(rule_set.file_path.as_str());
72 let target = crate::toml_writer::rule_set_table(rule_set);
73 let text = crate::toml_writer::render_rule_set_toml(rule_set);
74 rule_set_renders.push((path, target, text));
75 }
76
77 // --- Compute changed-file set ---------------------------------
78 // Unchanged in spirit from before RFC 056: compares the
79 // canonical render to the canonical baseline, so hand
80 // formatting on a never-edited file is never "changed".
81 let mut to_write: Vec<(PathBuf, toml::value::Table, String)> = Vec::new();
82
83 let baseline_root = self.baseline_files.get(&self.root_path);
84 if baseline_root.map(String::as_str) != Some(new_root_toml.as_str()) {
85 to_write.push((self.root_path.clone(), root_target, new_root_toml));
86 }
87 for (path, target, text) in rule_set_renders {
88 let baseline = self.baseline_files.get(&path);
89 if baseline.map(String::as_str) != Some(text.as_str()) {
90 to_write.push((path, target, text));
91 }
92 }
93
94 // --- Q3: refuse rather than overwrite a file that changed on
95 // disk since load()/save() last saw it. Checked for every file
96 // up front, before any write. A read failure (permission
97 // denied, the file deleted) is reported as `Read`, not folded
98 // into `Conflict` — the two need different remedies, and
99 // `Conflict`'s message ("reload before saving") would be
100 // actively wrong advice for a permission error. --------------
101 for (path, _, _) in &to_write {
102 if let Some(original) = self.original_text.get(path) {
103 match std::fs::read_to_string(path) {
104 Ok(current) if ¤t != original => {
105 return Err(SaveError::Conflict { path: path.clone() });
106 }
107 Ok(_) => {}
108 Err(source) => {
109 return Err(SaveError::Read {
110 path: path.clone(),
111 source,
112 });
113 }
114 }
115 }
116 }
117
118 // --- Mutate each file's own previous text in place, so
119 // comments / blank lines / key order survive. Falls back to
120 // the canonical render only when we never captured original
121 // text for a path (see the doc comment on `original_text`). --
122 let mut written: Vec<PathBuf> = Vec::with_capacity(to_write.len());
123 let mut fresh_text: HashMap<PathBuf, String> = HashMap::new();
124 let mut fresh_baseline: HashMap<PathBuf, String> = HashMap::new();
125 for (path, target, rendered) in &to_write {
126 let text =
127 match self.original_text.get(path) {
128 Some(original) => crate::toml_writer::apply_in_place(original, target)
129 .map_err(|source| SaveError::Inconsistent {
130 reason: format!(
131 "`{}` could not be re-parsed for an in-place save: {source}",
132 path.display()
133 ),
134 })?,
135 None => rendered.clone(),
136 };
137 atomic_write(path, &text)?;
138 fresh_text.insert(path.clone(), text);
139 fresh_baseline.insert(path.clone(), rendered.clone());
140 written.push(path.clone());
141 }
142
143 // --- Build diff_summary BEFORE updating baseline ------------
144 // The diff is "what did this save flush to disk", computed
145 // against the *previous* baseline. Once we refresh the
146 // baseline below, every node would compare equal again.
147 let diff_summary = self.compute_diff_summary();
148
149 // --- Refresh baselines ----------------------------------------
150 for (path, rendered) in fresh_baseline {
151 self.baseline_files.insert(path, rendered);
152 }
153 for (path, text) in fresh_text {
154 self.original_text.insert(path, text);
155 }
156 // Refresh mtime snapshots so has_external_changes() doesn't
157 // immediately fire for files we just wrote (RFC 024).
158 for path in self.baseline_files.keys() {
159 if let Ok(meta) = std::fs::metadata(path)
160 && let Ok(modified) = meta.modified()
161 {
162 self.file_metas.insert(
163 path.clone(),
164 crate::workspace::FileMeta {
165 modified,
166 len: meta.len(),
167 },
168 );
169 }
170 }
171
172 // --- Reload hint --------------------------------------------
173 // If the root file (which holds [listener]) was rewritten we
174 // conservatively flag a restart. Otherwise rule-set-only changes
175 // are a plain reload.
176 let listener_changed = written.contains(&self.root_path);
177 let requires_reload = listener_changed || !written.is_empty();
178
179 Ok(SaveResult {
180 changed_files: written,
181 diff_summary,
182 requires_reload,
183 })
184 }
185 /// True when at least one editable file's rendered output differs
186 /// from its load-time baseline.
187 ///
188 /// # Use case
189 ///
190 /// A GUI's "unsaved changes" indicator polls this. Cheap relative
191 /// to a full save (no file I/O, just renders + string compares).
192 pub fn has_unsaved_changes(&self) -> bool {
193 let root_text = crate::toml_writer::render_apimock_toml(&self.config);
194 if self.baseline_files.get(&self.root_path).map(|s| s.as_str()) != Some(root_text.as_str())
195 {
196 return true;
197 }
198 for rule_set in self.config.service.rule_sets.iter() {
199 let path = PathBuf::from(rule_set.file_path.as_str());
200 let text = crate::toml_writer::render_rule_set_toml(rule_set);
201 if self.baseline_files.get(&path).map(|s| s.as_str()) != Some(text.as_str()) {
202 return true;
203 }
204 }
205 false
206 }
207}
208
209/// Write `text` to `path` atomically.
210///
211/// # Why a tempfile + persist instead of a direct write
212///
213/// `std::fs::write` is two syscalls (truncate + write) with a window
214/// between them where a concurrent reader can see an empty file. The
215/// running apimock server reads its own config files when (eventually)
216/// it supports reload; if it picks a moment in the middle of
217/// `std::fs::write`, it can fail to parse a half-written TOML.
218///
219/// `tempfile::NamedTempFile::persist` writes to `<dir>/.tmpXXXX`,
220/// `fsync`s, then `rename(2)`s onto the destination — a single
221/// directory-entry update that the kernel guarantees is atomic. On
222/// Windows, `tempfile` translates this into `MoveFileExW` with the
223/// replace-existing flag for the same effect.
224///
225/// # Error mapping
226///
227/// `tempfile`'s persist returns a `PersistError` that wraps both the
228/// `NamedTempFile` and the underlying `io::Error`. We unwrap the
229/// `io::Error` and surface it as `SaveError::Write`. The temp file
230/// is dropped automatically (and removed) when the persist error
231/// returns.
232fn atomic_write(path: &Path, text: &str) -> Result<(), SaveError> {
233 let parent = path
234 .parent()
235 .filter(|p| !p.as_os_str().is_empty())
236 .map(Path::to_path_buf)
237 .unwrap_or_else(|| PathBuf::from("."));
238
239 let mut tmp = tempfile::NamedTempFile::new_in(&parent).map_err(|e| SaveError::Write {
240 path: path.to_path_buf(),
241 source: e,
242 })?;
243
244 use std::io::Write;
245 tmp.write_all(text.as_bytes())
246 .map_err(|e| SaveError::Write {
247 path: path.to_path_buf(),
248 source: e,
249 })?;
250 tmp.flush().map_err(|e| SaveError::Write {
251 path: path.to_path_buf(),
252 source: e,
253 })?;
254
255 tmp.persist(path).map_err(|persist_err| SaveError::Write {
256 path: path.to_path_buf(),
257 source: persist_err.error,
258 })?;
259 Ok(())
260}