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 diff;
54mod edit;
55mod id_index;
56mod path_helpers;
57mod save;
58mod snapshot;
59mod validate;
60
61#[cfg(test)]
62mod tests;
63
64use id_index::{IdIndex, NodeAddress};
65use path_helpers::resolve_root;
66
67/// Editable view of an apimock workspace.
68///
69/// # Internal layout
70///
71/// The `Workspace` holds the loaded TOML model (as a `Config`) plus
72/// two index maps:
73///
74/// - `id_to_address`: NodeId → where the node lives in `config`.
75/// - `address_to_id`: reverse — used when rebuilding snapshots.
76///
77/// On every `apply()` that could move nodes around (Add / Remove /
78/// Move), these tables are partially rebuilt. Reloading the config
79/// discards them and re-seeds with fresh IDs.
80pub struct Workspace {
81 /// Path this workspace was loaded from.
82 pub(super) root_path: PathBuf,
83 /// Loaded TOML model. Authoritative source of truth for
84 /// persistence; edits happen through the editable helpers on
85 /// `Workspace` which keep `config` + id tables in sync.
86 pub(super) config: Config,
87 /// ID index — see struct doc.
88 pub(super) ids: IdIndex,
89 /// Workspace-scope diagnostics (e.g. load-time warnings). Per-node
90 /// diagnostics live inside each node's `NodeValidation`.
91 pub(super) diagnostics: Vec<Diagnostic>,
92 /// Snapshot of every editable file's rendered baseline at load
93 /// time (or after the most recent successful save). `save()` uses
94 /// this to skip files whose rendered output is byte-identical to
95 /// the baseline; `has_unsaved_changes()` uses it as a cheap "is
96 /// the model dirty" check.
97 pub(super) baseline_files: HashMap<PathBuf, String>,
98}
99
100impl Workspace {
101 /// Load a workspace rooted at the given `apimock.toml`-like path.
102 ///
103 /// Accepts either a direct path to the config file or the
104 /// directory containing one; a missing file-path is searched for
105 /// as `apimock.toml` inside `root`. Mirrors the CLI's existing
106 /// resolution rules.
107 pub fn load(root: PathBuf) -> Result<Self, WorkspaceError> {
108 let resolved = resolve_root(&root)?;
109
110 // Re-use `Config::new` so rule-set loading + validation go
111 // through the same path as the running server. This is
112 // important — the spec's "GUI doesn't break running server
113 // behaviour" invariant (§13) is easiest to guarantee if both
114 // paths share the same loader.
115 let config_path_string = resolved.to_string_lossy().into_owned();
116 let config = Config::new(Some(&config_path_string), None).map_err(WorkspaceError::from)?;
117
118 // Snapshot every TOML file's rendered shape so save() can
119 // tell which files actually have unsaved edits.
120 //
121 // # Why "rendered model" rather than "on-disk text"
122 //
123 // A naive baseline would store the literal on-disk text. But
124 // our writer (`toml_writer`) produces canonicalised TOML —
125 // sorted keys, no comments, double-quoted strings, etc. —
126 // which almost never byte-matches a hand-edited file. With
127 // "on-disk" baseline, `has_unsaved_changes` would return
128 // `true` right after a load with no edits, and the first
129 // save would unconditionally rewrite every file.
130 //
131 // Storing the *rendered* baseline solves this: a freshly
132 // loaded workspace has rendered == baseline by construction,
133 // so `has_unsaved_changes` is false. Edits flip it to true,
134 // and only the files that diverge get rewritten on save.
135 // The user's hand-formatting on never-edited files survives
136 // untouched.
137 let mut baseline_files: HashMap<PathBuf, String> = HashMap::new();
138 baseline_files.insert(
139 resolved.clone(),
140 crate::toml_writer::render_apimock_toml(&config),
141 );
142 for rule_set in config.service.rule_sets.iter() {
143 let path = PathBuf::from(rule_set.file_path.as_str());
144 baseline_files.insert(
145 path,
146 crate::toml_writer::render_rule_set_toml(rule_set),
147 );
148 }
149
150 let mut workspace = Self {
151 root_path: resolved,
152 config,
153 ids: IdIndex::default(),
154 diagnostics: Vec::new(),
155 baseline_files,
156 };
157 workspace.seed_ids();
158 Ok(workspace)
159 }
160
161 /// Assign a fresh NodeId to every editable address in `config`.
162 /// Called from `load` and from any `apply()` path that might
163 /// change the address of existing nodes.
164 ///
165 /// # Why we rebuild rather than patch
166 ///
167 /// `NodeAddress` carries positional indices (`rule_set: usize`).
168 /// When a rule is deleted from the middle of a list, every rule
169 /// after it gets a new index, so its `NodeAddress` changes. The
170 /// GUI's NodeId must *not* change — that's the whole point of
171 /// UUIDs — so this function preserves the existing
172 /// address_to_id mapping where addresses still exist and only
173 /// mints new IDs for genuinely new addresses.
174 ///
175 /// For Step 1 there's nothing to preserve: load is a from-scratch
176 /// operation. Step 2 will call a more careful `reseed_after_edit`.
177 fn seed_ids(&mut self) {
178 // Root is always present.
179 self.ids.insert(NodeAddress::Root);
180
181 // Fallback respond dir is always present — even if the user
182 // hasn't set it, it has a default value.
183 self.ids.insert(NodeAddress::FallbackRespondDir);
184
185 // Rule sets + their rules + respond blocks.
186 for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
187 self.ids.insert(NodeAddress::RuleSet { rule_set: rs_idx });
188 for (rule_idx, _rule) in rule_set.rules.iter().enumerate() {
189 self.ids.insert(NodeAddress::Rule {
190 rule_set: rs_idx,
191 rule: rule_idx,
192 });
193 self.ids.insert(NodeAddress::Respond {
194 rule_set: rs_idx,
195 rule: rule_idx,
196 });
197 }
198 }
199
200 // Middleware references.
201 if let Some(paths) = self.config.service.middlewares_file_paths.as_ref() {
202 for mw_idx in 0..paths.len() {
203 self.ids
204 .insert(NodeAddress::Middleware { middleware: mw_idx });
205 }
206 }
207 }
208
209 /// Resolve a relative path against the config file's parent dir.
210 /// Used by snapshot rendering and by `cmd_add_rule_set`.
211 pub(super) fn config_relative_dir(&self) -> Result<String, ConfigError> {
212 self.config.current_dir_to_parent_dir_relative_path()
213 }
214
215 /// Joins a relative TOML path string against the config's parent
216 /// directory. Used by snapshot rendering when materialising
217 /// middleware / fallback dir paths for display.
218 pub(super) fn resolve_relative(&self, rel: &str) -> PathBuf {
219 match self.config.current_dir_to_parent_dir_relative_path() {
220 Ok(dir) => Path::new(&dir).join(rel),
221 Err(_) => PathBuf::from(rel),
222 }
223 }
224
225 /// Access the underlying `Config`. Intended for embedders that
226 /// need to build a running `Server` from the same workspace. Edit
227 /// via `apply()` instead of touching `Config` directly — changes
228 /// made through this reference are invisible to the ID index.
229 pub fn config(&self) -> &Config {
230 &self.config
231 }
232
233 /// Access the root path. Primarily for diagnostics.
234 pub fn root_path(&self) -> &Path {
235 &self.root_path
236 }
237
238 /// Expand a directory in the file tree on demand.
239 ///
240 /// # When the GUI calls this
241 ///
242 /// `Workspace::snapshot()` returns a `FileTreeView` populated with
243 /// just the top-level entries of the fallback respond dir. Each
244 /// directory entry carries `children: Some(Vec::new())` to flag it
245 /// as expandable. When a user clicks to expand one of those nodes,
246 /// the GUI calls `list_directory(&entry.path)` and gets back the
247 /// next depth's entries (still not recursed past that depth — the
248 /// same lazy contract holds).
249 ///
250 /// # Why path-based and not NodeId-based
251 ///
252 /// File-tree entries don't carry NodeIds (see `FileNodeView`). The
253 /// reason is lifecycle: the editable node space (rules, rule sets,
254 /// respond blocks) is small, stable, and survives `apply()` calls
255 /// — perfect for UUID-keyed state. The file tree is large,
256 /// transient, and reflects the filesystem rather than the model;
257 /// keying it by path keeps the API simple and avoids mixing two
258 /// kinds of identity.
259 pub fn list_directory(&self, path: &Path) -> Vec<apimock_routing::view::FileNodeView> {
260 let filter = self
261 .config
262 .file_tree_view
263 .as_ref()
264 .map(|c| c.to_filter())
265 .unwrap_or_default();
266 apimock_routing::view::build::list_directory_with(path, &filter)
267 }
268}