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 // Refresh mtime snapshots so has_external_changes() doesn't
104 // immediately fire for files we just wrote (RFC 024).
105 for path in self.baseline_files.keys() {
106 if let Ok(meta) = std::fs::metadata(path) {
107 if let Ok(modified) = meta.modified() {
108 self.file_metas.insert(
109 path.clone(),
110 crate::workspace::FileMeta { modified, len: meta.len() },
111 );
112 }
113 }
114 }
115
116 // --- Reload hint --------------------------------------------
117 // If the root file (which holds [listener]) was rewritten we
118 // conservatively flag a restart. Otherwise rule-set-only changes
119 // are a plain reload.
120 let listener_changed = written.contains(&self.root_path);
121 let requires_reload = listener_changed || !written.is_empty();
122
123 Ok(SaveResult {
124 changed_files: written,
125 diff_summary,
126 requires_reload,
127 })
128 }
129 /// True when at least one editable file's rendered output differs
130 /// from its load-time baseline.
131 ///
132 /// # Use case
133 ///
134 /// A GUI's "unsaved changes" indicator polls this. Cheap relative
135 /// to a full save (no file I/O, just renders + string compares).
136 pub fn has_unsaved_changes(&self) -> bool {
137 let root_text = crate::toml_writer::render_apimock_toml(&self.config);
138 if self
139 .baseline_files
140 .get(&self.root_path)
141 .map(|s| s.as_str())
142 != Some(root_text.as_str())
143 {
144 return true;
145 }
146 for rule_set in self.config.service.rule_sets.iter() {
147 let path = PathBuf::from(rule_set.file_path.as_str());
148 let text = crate::toml_writer::render_rule_set_toml(rule_set);
149 if self
150 .baseline_files
151 .get(&path)
152 .map(|s| s.as_str())
153 != Some(text.as_str())
154 {
155 return true;
156 }
157 }
158 false
159 }
160}
161
162/// Write `text` to `path` atomically.
163///
164/// # Why a tempfile + persist instead of a direct write
165///
166/// `std::fs::write` is two syscalls (truncate + write) with a window
167/// between them where a concurrent reader can see an empty file. The
168/// running apimock server reads its own config files when (eventually)
169/// it supports reload; if it picks a moment in the middle of
170/// `std::fs::write`, it can fail to parse a half-written TOML.
171///
172/// `tempfile::NamedTempFile::persist` writes to `<dir>/.tmpXXXX`,
173/// `fsync`s, then `rename(2)`s onto the destination — a single
174/// directory-entry update that the kernel guarantees is atomic. On
175/// Windows, `tempfile` translates this into `MoveFileExW` with the
176/// replace-existing flag for the same effect.
177///
178/// # Error mapping
179///
180/// `tempfile`'s persist returns a `PersistError` that wraps both the
181/// `NamedTempFile` and the underlying `io::Error`. We unwrap the
182/// `io::Error` and surface it as `SaveError::Write`. The temp file
183/// is dropped automatically (and removed) when the persist error
184/// returns.
185fn atomic_write(path: &Path, text: &str) -> Result<(), SaveError> {
186 let parent = path
187 .parent()
188 .filter(|p| !p.as_os_str().is_empty())
189 .map(Path::to_path_buf)
190 .unwrap_or_else(|| PathBuf::from("."));
191
192 let mut tmp =
193 tempfile::NamedTempFile::new_in(&parent).map_err(|e| SaveError::Write {
194 path: path.to_path_buf(),
195 source: e,
196 })?;
197
198 use std::io::Write;
199 tmp.write_all(text.as_bytes())
200 .map_err(|e| SaveError::Write {
201 path: path.to_path_buf(),
202 source: e,
203 })?;
204 tmp.flush().map_err(|e| SaveError::Write {
205 path: path.to_path_buf(),
206 source: e,
207 })?;
208
209 tmp.persist(path).map_err(|persist_err| SaveError::Write {
210 path: path.to_path_buf(),
211 source: persist_err.error,
212 })?;
213 Ok(())
214}