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::path::{Path, PathBuf};
24
25use crate::error::SaveError;
26use crate::view::SaveResult;
27
28use super::Workspace;
29
30impl Workspace {
31 /// Save the workspace back to disk.
32 ///
33 /// # Algorithm
34 ///
35 /// 1. Render each editable file (root + each rule set) to TOML text.
36 /// 2. Compare against `baseline_files`. Files whose rendered output
37 /// is byte-identical to the baseline are skipped — the user's
38 /// formatting / comments survive untouched in that case.
39 /// 3. For files that *do* differ, write atomically via
40 /// `tempfile::NamedTempFile::persist` (same-directory rename(2)
41 /// on POSIX, `MoveFileExW` on Windows). On any single-file
42 /// failure, the partial state is whatever rename(2)s have
43 /// already succeeded — see the type-level docstring on
44 /// `SaveError` for the rationale.
45 /// 4. After all writes succeed, refresh `baseline_files` so a
46 /// subsequent save() won't re-write the same files needlessly.
47 /// 5. Compute `DiffItem`s by node, comparing the in-memory state
48 /// to the load-time baseline (parsed; not text-diff).
49 /// 6. Compute `requires_reload` / `requires_restart` from the set
50 /// of changed files: changes to `[listener]` need a restart,
51 /// everything else just a reload.
52 ///
53 /// # The "save loses comments" diagnostic
54 ///
55 /// Per the GUI spec §6 / §11, save is allowed to lose comments and
56 /// formatting. We surface this as an `Info`-severity diagnostic
57 /// the first time a save would actually overwrite a file that has
58 /// non-trivial formatting (any file whose TOML round-trip is not
59 /// byte-identical, which is essentially every hand-edited file).
60 /// A polished GUI shows it once per session.
61 pub fn save(&mut self) -> Result<SaveResult, SaveError> {
62 // --- Render every file's new content -------------------------
63 let new_root_toml = crate::toml_writer::render_apimock_toml(&self.config);
64
65 let mut rule_set_renders: Vec<(PathBuf, String)> = Vec::new();
66 for rule_set in self.config.service.rule_sets.iter() {
67 let path = PathBuf::from(rule_set.file_path.as_str());
68 let text = crate::toml_writer::render_rule_set_toml(rule_set);
69 rule_set_renders.push((path, text));
70 }
71
72 // --- Compute changed-file set --------------------------------
73 let mut to_write: Vec<(PathBuf, String)> = Vec::new();
74
75 let baseline_root = self.baseline_files.get(&self.root_path);
76 if baseline_root.map(String::as_str) != Some(new_root_toml.as_str()) {
77 to_write.push((self.root_path.clone(), new_root_toml.clone()));
78 }
79 for (path, text) in rule_set_renders.iter() {
80 let baseline = self.baseline_files.get(path);
81 if baseline.map(String::as_str) != Some(text.as_str()) {
82 to_write.push((path.clone(), text.clone()));
83 }
84 }
85
86 // --- Atomic write via tempfile::persist ----------------------
87 let mut written: Vec<PathBuf> = Vec::with_capacity(to_write.len());
88 for (path, text) in &to_write {
89 atomic_write(path, text)?;
90 written.push(path.clone());
91 }
92
93 // --- Build diff_summary BEFORE updating baseline ------------
94 // The diff is "what did this save flush to disk", computed
95 // against the *previous* baseline. Once we refresh the
96 // baseline below, every node would compare equal again.
97 let diff_summary = self.compute_diff_summary();
98
99 // --- Refresh baseline ---------------------------------------
100 for (path, text) in to_write.into_iter() {
101 self.baseline_files.insert(path, text);
102 }
103
104 // --- Reload hint --------------------------------------------
105 // If the root file (which holds [listener]) was rewritten we
106 // conservatively flag a restart. Otherwise rule-set-only changes
107 // are a plain reload.
108 let listener_changed = written.contains(&self.root_path);
109 let requires_reload = listener_changed || !written.is_empty();
110
111 Ok(SaveResult {
112 changed_files: written,
113 diff_summary,
114 requires_reload,
115 })
116 }
117 /// True when at least one editable file's rendered output differs
118 /// from its load-time baseline.
119 ///
120 /// # Use case
121 ///
122 /// A GUI's "unsaved changes" indicator polls this. Cheap relative
123 /// to a full save (no file I/O, just renders + string compares).
124 pub fn has_unsaved_changes(&self) -> bool {
125 let root_text = crate::toml_writer::render_apimock_toml(&self.config);
126 if self
127 .baseline_files
128 .get(&self.root_path)
129 .map(|s| s.as_str())
130 != Some(root_text.as_str())
131 {
132 return true;
133 }
134 for rule_set in self.config.service.rule_sets.iter() {
135 let path = PathBuf::from(rule_set.file_path.as_str());
136 let text = crate::toml_writer::render_rule_set_toml(rule_set);
137 if self
138 .baseline_files
139 .get(&path)
140 .map(|s| s.as_str())
141 != Some(text.as_str())
142 {
143 return true;
144 }
145 }
146 false
147 }
148}
149
150/// Write `text` to `path` atomically.
151///
152/// # Why a tempfile + persist instead of a direct write
153///
154/// `std::fs::write` is two syscalls (truncate + write) with a window
155/// between them where a concurrent reader can see an empty file. The
156/// running apimock server reads its own config files when (eventually)
157/// it supports reload; if it picks a moment in the middle of
158/// `std::fs::write`, it can fail to parse a half-written TOML.
159///
160/// `tempfile::NamedTempFile::persist` writes to `<dir>/.tmpXXXX`,
161/// `fsync`s, then `rename(2)`s onto the destination — a single
162/// directory-entry update that the kernel guarantees is atomic. On
163/// Windows, `tempfile` translates this into `MoveFileExW` with the
164/// replace-existing flag for the same effect.
165///
166/// # Error mapping
167///
168/// `tempfile`'s persist returns a `PersistError` that wraps both the
169/// `NamedTempFile` and the underlying `io::Error`. We unwrap the
170/// `io::Error` and surface it as `SaveError::Write`. The temp file
171/// is dropped automatically (and removed) when the persist error
172/// returns.
173fn atomic_write(path: &Path, text: &str) -> Result<(), SaveError> {
174 let parent = path
175 .parent()
176 .filter(|p| !p.as_os_str().is_empty())
177 .map(Path::to_path_buf)
178 .unwrap_or_else(|| PathBuf::from("."));
179
180 let mut tmp =
181 tempfile::NamedTempFile::new_in(&parent).map_err(|e| SaveError::Write {
182 path: path.to_path_buf(),
183 source: e,
184 })?;
185
186 use std::io::Write;
187 tmp.write_all(text.as_bytes())
188 .map_err(|e| SaveError::Write {
189 path: path.to_path_buf(),
190 source: e,
191 })?;
192 tmp.flush().map_err(|e| SaveError::Write {
193 path: path.to_path_buf(),
194 source: e,
195 })?;
196
197 tmp.persist(path).map_err(|persist_err| SaveError::Write {
198 path: path.to_path_buf(),
199 source: persist_err.error,
200 })?;
201 Ok(())
202}