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
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}