Skip to main content

apimock_config/
workspace.rs

1//! The editable workspace: loaded TOML + stable node IDs + edit API.
2//!
3//! # Role in the design
4//!
5//! A GUI never touches `Config` or `RuleSet` directly. It holds a
6//! `Workspace` value, calls `snapshot()` to get a read-only view for
7//! rendering, and `apply(EditCommand)` to mutate. Later, `save()`
8//! writes changes back to disk.
9//!
10//! # Module layout
11//!
12//! `Workspace` is large enough that its impl is split across several
13//! sibling modules under `workspace/`:
14//!
15//! - `id_index` — `NodeAddress` + `IdIndex` machinery
16//! - `snapshot` — `Workspace::snapshot()` and per-file view builders
17//! - `edit` — `Workspace::apply()` and the eight `EditCommand`
18//!   handlers (with the `id_shift` and `payload` submodules)
19//! - `validate` — `Workspace::validate()` and the per-node walker
20//! - `save` — `Workspace::save()`, `has_unsaved_changes()`, and
21//!   the atomic-write helper
22//! - `diff` — `compute_diff_summary()` and per-rule comparison
23//! - `path_helpers` — small filesystem utilities reused by the
24//!   submodules above
25//!
26//! Each submodule is private — the public surface remains
27//! `apimock_config::Workspace` and the methods it exposes.
28//!
29//! This file holds the `Workspace` struct itself, the `load` /
30//! `seed_ids` lifecycle, plus the small accessor methods that don't
31//! belong in any of the larger groupings.
32//!
33//! # IDs
34//!
35//! Every editable node gets a v4 UUID at load time. IDs are stable
36//! across `apply()` calls within one `Workspace` instance, so GUI
37//! selection survives edits that reorder or rename surrounding nodes.
38//! IDs are *not* stable across fresh `load()` calls — a reload
39//! regenerates the table, which matches the spec §10 "Workspace は
40//! メモリ上に独立インスタンスを持つ" stance.
41
42use std::{
43    collections::HashMap,
44    path::{Path, PathBuf},
45};
46
47use crate::{
48    Config,
49    error::{ConfigError, WorkspaceError},
50    view::Diagnostic,
51};
52
53mod address;
54mod diff;
55mod edit;
56mod id_index;
57mod path_helpers;
58mod save;
59mod snapshot;
60mod validate;
61
62#[cfg(test)]
63mod tests;
64
65use id_index::{IdIndex, NodeAddress};
66use path_helpers::resolve_root;
67
68/// Editable view of an apimock workspace.
69///
70/// # Internal layout
71///
72/// The `Workspace` holds the loaded TOML model (as a `Config`) plus
73/// two index maps:
74///
75/// - `id_to_address`: NodeId → where the node lives in `config`.
76/// - `address_to_id`: reverse — used when rebuilding snapshots.
77///
78/// On every `apply()` that could move nodes around (Add / Remove /
79/// Move), these tables are partially rebuilt. Reloading the config
80/// discards them and re-seeds with fresh IDs.
81pub struct Workspace {
82    /// Path this workspace was loaded from.
83    pub(super) root_path: PathBuf,
84    /// Loaded TOML model.
85    pub(super) config: Config,
86    /// ID index.
87    pub(super) ids: IdIndex,
88    /// Workspace-scope diagnostics.
89    pub(super) diagnostics: Vec<Diagnostic>,
90    /// Rendered baseline for save/diff detection.
91    pub(super) baseline_files: HashMap<PathBuf, String>,
92    /// Each file's own on-disk text as of the last `load()` or
93    /// successful `save()`. Two jobs (RFC 056):
94    ///
95    /// - the base `save()` mutates in place via `toml_writer::apply_in_place`,
96    ///   so a save's untouched keys keep their comments and order;
97    /// - the baseline `save()` compares fresh on-disk text against
98    ///   before writing, to refuse (`SaveError::Conflict`) rather than
99    ///   overwrite a file someone else changed since we last saw it.
100    ///
101    /// Distinct from `baseline_files`, which stays a *canonical
102    /// rendering* by design (§2 Q1) — this field is the only place raw
103    /// on-disk text is kept.
104    pub(super) original_text: HashMap<PathBuf, String>,
105    /// Modification-time + size snapshot of every loaded file,
106    /// captured at `load()` and refreshed after each `save()`.
107    /// Used by `has_external_changes()` and `sync_from_disk()`.
108    pub(super) file_metas: HashMap<PathBuf, FileMeta>,
109}
110
111/// Snapshot of one file's modification-time and size.
112#[derive(Clone, Debug)]
113pub(super) struct FileMeta {
114    pub modified: std::time::SystemTime,
115    pub len: u64,
116}
117
118impl Workspace {
119    /// Load a workspace rooted at the given `apimock.toml`-like path.
120    ///
121    /// Accepts either a direct path to the config file or the
122    /// directory containing one; a missing file-path is searched for
123    /// as `apimock.toml` inside `root`. Mirrors the CLI's existing
124    /// resolution rules.
125    pub fn load(root: PathBuf) -> Result<Self, WorkspaceError> {
126        let resolved = resolve_root(&root)?;
127
128        // Re-use `Config::new` so rule-set loading + validation go
129        // through the same path as the running server. This is
130        // important — the spec's "GUI doesn't break running server
131        // behaviour" invariant (§13) is easiest to guarantee if both
132        // paths share the same loader.
133        let config_path_string = resolved.to_string_lossy().into_owned();
134        let config = Config::new(Some(&config_path_string), None).map_err(WorkspaceError::from)?;
135
136        // Snapshot every TOML file's rendered shape so save() can
137        // tell which files actually have unsaved edits.
138        //
139        // # Why "rendered model" rather than "on-disk text"
140        //
141        // A naive baseline would store the literal on-disk text. But
142        // our writer (`toml_writer`) produces canonicalised TOML —
143        // sorted keys, no comments, double-quoted strings, etc. —
144        // which almost never byte-matches a hand-edited file. With
145        // "on-disk" baseline, `has_unsaved_changes` would return
146        // `true` right after a load with no edits, and the first
147        // save would unconditionally rewrite every file.
148        //
149        // Storing the *rendered* baseline solves this: a freshly
150        // loaded workspace has rendered == baseline by construction,
151        // so `has_unsaved_changes` is false. Edits flip it to true,
152        // and only the files that diverge get rewritten on save.
153        //
154        // That "which files diverge" question is still the rendered
155        // baseline's job even after RFC 056. The literal on-disk text
156        // is captured separately below, into `original_text` — that's
157        // the in-place mutation source and the Q3 conflict baseline,
158        // not a second change-detection mechanism.
159        // The user's hand-formatting on never-edited files survives
160        // untouched.
161        let mut baseline_files: HashMap<PathBuf, String> = HashMap::new();
162        baseline_files.insert(
163            resolved.clone(),
164            crate::toml_writer::render_apimock_toml(&config),
165        );
166        for rule_set in config.service.rule_sets.iter() {
167            let path = PathBuf::from(rule_set.file_path.as_str());
168            baseline_files.insert(path, crate::toml_writer::render_rule_set_toml(rule_set));
169        }
170
171        // Capture each file's own text as of this load (RFC 056): the
172        // mutation source for a later in-place save, and the baseline
173        // Q3's conflict check compares fresh reads against. A read
174        // failure here (the file vanishing between `Config::new`'s
175        // read and this one) just leaves no entry — `save()` falls
176        // back to a canonical re-render for that one path rather than
177        // failing the whole save over an unrelated, narrow race.
178        let mut original_text: HashMap<PathBuf, String> = HashMap::new();
179        if let Ok(text) = std::fs::read_to_string(&resolved) {
180            original_text.insert(resolved.clone(), text);
181        }
182        for rule_set in config.service.rule_sets.iter() {
183            let path = PathBuf::from(rule_set.file_path.as_str());
184            if let Ok(text) = std::fs::read_to_string(&path) {
185                original_text.insert(path, text);
186            }
187        }
188
189        // Snapshot file metadata for external-change detection (RFC 024).
190        let mut file_metas: HashMap<PathBuf, FileMeta> = HashMap::new();
191        for path in baseline_files.keys() {
192            if let Ok(meta) = std::fs::metadata(path)
193                && let Ok(modified) = meta.modified()
194            {
195                file_metas.insert(
196                    path.clone(),
197                    FileMeta {
198                        modified,
199                        len: meta.len(),
200                    },
201                );
202            }
203        }
204
205        let mut workspace = Self {
206            root_path: resolved,
207            config,
208            ids: IdIndex::default(),
209            diagnostics: Vec::new(),
210            baseline_files,
211            original_text,
212            file_metas,
213        };
214        workspace.seed_ids();
215        Ok(workspace)
216    }
217
218    // ── RFC 024: external-change detection ───────────────────────────────
219
220    /// Returns `true` if any tracked config file has been modified on disk
221    /// since the last `load()` or `save()`.
222    ///
223    /// Polls file metadata (mtime + size). Returns `false` on stat errors
224    /// to avoid spurious "changed" signals from transient temp-file churn.
225    ///
226    /// # Usage
227    ///
228    /// Call periodically from the GUI and re-render when `true`:
229    ///
230    /// ```rust,no_run
231    /// # use apimock_config::Workspace;
232    /// # let mut ws = Workspace::load("apimock.toml".into()).unwrap();
233    /// if ws.has_external_changes() {
234    ///     ws.sync_from_disk().unwrap();
235    /// }
236    /// ```
237    pub fn has_external_changes(&self) -> bool {
238        for (path, recorded) in &self.file_metas {
239            if let Ok(meta) = std::fs::metadata(path) {
240                let changed_size = meta.len() != recorded.len;
241                let changed_mtime = meta
242                    .modified()
243                    .map(|m| m != recorded.modified)
244                    .unwrap_or(false);
245                if changed_size || changed_mtime {
246                    return true;
247                }
248            }
249            // Stat error (file deleted, permission) → treat as unchanged.
250        }
251        false
252    }
253
254    /// Reload all config files from disk, replacing the in-memory model.
255    ///
256    /// # Every `NodeId` is reassigned (RFC 042)
257    ///
258    /// A sync is a fresh [`load`](Self::load): the old `IdIndex` is
259    /// discarded and a new one is seeded from scratch, so **every**
260    /// `NodeId` changes, whether or not the address it names actually
261    /// did. This previously stated the opposite — that IDs for
262    /// unchanged addresses survived — which was never true; `*self =
263    /// fresh` here has always replaced the whole workspace, ID index
264    /// included. RFC 042 corrects the claim rather than building the
265    /// preservation it described, because `NodeAddress` is positional
266    /// (`Rule { rule_set: usize, rule: usize }`) and an external edit —
267    /// the one case this method exists for — is exactly the case where
268    /// positions shift, making "preserve by address" reassign identity
269    /// onto the wrong rule as often as it would help.
270    ///
271    /// **After a sync, re-read the tree — do not reuse a `NodeId` held
272    /// from before the call.** A GUI calling this is already
273    /// re-rendering (it just observed [`has_external_changes`](Self::has_external_changes)
274    /// return `true`), so re-querying every ID from the new
275    /// [`snapshot`](Self::snapshot) costs nothing extra.
276    ///
277    /// On parse error, the workspace is left unchanged and the error is
278    /// returned. The GUI can surface the error and retry.
279    ///
280    /// After a successful sync, `has_external_changes()` returns `false`
281    /// until the next external modification.
282    pub fn sync_from_disk(&mut self) -> Result<(), WorkspaceError> {
283        let fresh = Self::load(self.root_path.clone())?;
284        // Replace the entire workspace state. NodeIDs are re-seeded from
285        // scratch; GUI callers should treat a sync like a fresh load and
286        // re-query all NodeIds from the new snapshot.
287        *self = fresh;
288        Ok(())
289    }
290
291    /// Assign a fresh NodeId to every editable address in `config`.
292    /// Called from `load` and from any `apply()` path that might
293    /// change the address of existing nodes.
294    ///
295    /// # Why we rebuild rather than patch
296    ///
297    /// `NodeAddress` carries positional indices (`rule_set: usize`).
298    /// When a rule is deleted from the middle of a list, every rule
299    /// after it gets a new index, so its `NodeAddress` changes. The
300    /// GUI's NodeId must *not* change — that's the whole point of
301    /// UUIDs — so this function preserves the existing
302    /// address_to_id mapping where addresses still exist and only
303    /// mints new IDs for genuinely new addresses.
304    ///
305    /// Called from `load()`, there's nothing to preserve: loading is a
306    /// from-scratch operation, and `sync_from_disk` (a fresh `load()`
307    /// under the hood) reassigns every `NodeId` for exactly that reason —
308    /// see its own doc comment (RFC 042). There is no `reseed_after_edit`
309    /// function; an earlier version of this comment referred to one that
310    /// was never built.
311    fn seed_ids(&mut self) {
312        // Root is always present.
313        self.ids.insert(NodeAddress::Root);
314
315        // Fallback respond dir is always present — even if the user
316        // hasn't set it, it has a default value.
317        self.ids.insert(NodeAddress::FallbackRespondDir);
318
319        // Rule sets + their rules + respond blocks.
320        for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
321            self.ids.insert(NodeAddress::RuleSet { rule_set: rs_idx });
322            for (rule_idx, _rule) in rule_set.rules.iter().enumerate() {
323                self.ids.insert(NodeAddress::Rule {
324                    rule_set: rs_idx,
325                    rule: rule_idx,
326                });
327                self.ids.insert(NodeAddress::Respond {
328                    rule_set: rs_idx,
329                    rule: rule_idx,
330                });
331            }
332        }
333
334        // Middleware references.
335        if let Some(paths) = self.config.service.middlewares_file_paths.as_ref() {
336            for mw_idx in 0..paths.len() {
337                self.ids
338                    .insert(NodeAddress::Middleware { middleware: mw_idx });
339            }
340        }
341    }
342
343    /// Resolve a relative path against the config file's parent dir.
344    /// Used by snapshot rendering and by `cmd_add_rule_set`.
345    pub(super) fn config_relative_dir(&self) -> Result<String, ConfigError> {
346        self.config.current_dir_to_parent_dir_relative_path()
347    }
348
349    /// Joins a relative TOML path string against the config's parent
350    /// directory. Used by snapshot rendering when materialising
351    /// middleware / fallback dir paths for display.
352    pub(super) fn resolve_relative(&self, rel: &str) -> PathBuf {
353        match self.config.current_dir_to_parent_dir_relative_path() {
354            Ok(dir) => Path::new(&dir).join(rel),
355            Err(_) => PathBuf::from(rel),
356        }
357    }
358
359    /// Access the underlying `Config`. Intended for embedders that
360    /// need to build a running `Server` from the same workspace. Edit
361    /// via `apply()` instead of touching `Config` directly — changes
362    /// made through this reference are invisible to the ID index.
363    pub fn config(&self) -> &Config {
364        &self.config
365    }
366
367    /// Access the root path. Primarily for diagnostics.
368    pub fn root_path(&self) -> &Path {
369        &self.root_path
370    }
371
372    /// Expand a directory in the file tree on demand.
373    ///
374    /// # When the GUI calls this
375    ///
376    /// `Workspace::snapshot()` returns a `FileTreeView` populated with
377    /// just the top-level entries of the fallback respond dir. Each
378    /// directory entry carries `children: Some(Vec::new())` to flag it
379    /// as expandable. When a user clicks to expand one of those nodes,
380    /// the GUI calls `list_directory(&entry.path)` and gets back the
381    /// next depth's entries (still not recursed past that depth — the
382    /// same lazy contract holds).
383    ///
384    /// # Why path-based and not NodeId-based
385    ///
386    /// File-tree entries don't carry NodeIds (see `FileNodeView`). The
387    /// reason is lifecycle: the editable node space (rules, rule sets,
388    /// respond blocks) is small, stable, and survives `apply()` calls
389    /// — perfect for UUID-keyed state. The file tree is large,
390    /// transient, and reflects the filesystem rather than the model;
391    /// keying it by path keeps the API simple and avoids mixing two
392    /// kinds of identity.
393    pub fn list_directory(&self, path: &Path) -> Vec<apimock_routing::view::FileNodeView> {
394        let filter = self
395            .config
396            .file_tree_view
397            .as_ref()
398            .map(|c| c.to_filter())
399            .unwrap_or_default();
400        apimock_routing::view::build::list_directory_with(path, &filter)
401    }
402}