Skip to main content

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}