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::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 &current != 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}