apimock_config/view.rs
1//! Read-only views on workspace state, and the command + result types
2//! the editing API uses.
3//!
4//! # 5.1.0 — spec alignment
5//!
6//! In 5.0.0 this module carried a placeholder shape defined only by
7//! rustdoc; 5.1.0 re-aligns it with the 5.1 spec:
8//!
9//! - `WorkspaceSnapshot { files, routes, diagnostics }` (spec §4.2)
10//! - each node carries `id: NodeId` + `source_file` + `toml_path` +
11//! `display_name` + `kind` + `validation`
12//! - `EditCommand` is eight variants covering every editable action
13//! (spec §4.3)
14//! - `ApplyResult { changed_nodes, diagnostics, requires_reload }`
15//! (spec §4.4)
16//! - `SaveResult { changed_files, diff_summary, requires_reload }`
17//! (spec §4.5) — populated in Step 4
18//! - `ValidationReport { diagnostics, is_valid }` (spec §4.6)
19//! - `Diagnostic { node_id, file, severity, message }` (spec §4.7)
20//!
21//! # Why UUIDs and not positional IDs
22//!
23//! The spec's §4.3 says "すべて NodeId で対象を指定". Positional IDs
24//! (`rule_sets[0].rules[3]`) would shift on every insert / delete /
25//! move, forcing the GUI to re-index its selection set after every
26//! edit. UUIDs are stable within a `Workspace` instance regardless of
27//! reordering.
28
29use serde::{Deserialize, Serialize};
30use serde_json::Value as JsonValue;
31use uuid::Uuid;
32
33use std::path::PathBuf;
34
35use apimock_routing::view::RouteCatalogSnapshot;
36
37/// Stable identifier for an editable node.
38///
39/// # Stability contract
40///
41/// Stable within one `Workspace` instance — that is, across any
42/// sequence of `apply()` calls. IDs are reassigned on fresh `load()`,
43/// which matches spec §10 "Workspace はメモリ上に独立インスタンスを持つ".
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(transparent)]
46pub struct NodeId(pub Uuid);
47
48impl NodeId {
49 pub fn new() -> Self {
50 Self(Uuid::new_v4())
51 }
52}
53
54impl Default for NodeId {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl std::fmt::Display for NodeId {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 self.0.fmt(f)
63 }
64}
65
66/// Complete snapshot of the workspace state.
67///
68/// Shape matches spec §4.2 exactly. Consumed read-only by the GUI;
69/// mutated indirectly via `Workspace::apply`.
70#[derive(Clone, Debug, Serialize)]
71#[non_exhaustive]
72pub struct WorkspaceSnapshot {
73 /// All editable TOML files in the workspace, flattened. Each file
74 /// carries its own list of editable nodes.
75 pub files: Vec<ConfigFileView>,
76 /// Route overview pulled from the routing crate.
77 pub routes: RouteCatalogSnapshot,
78 /// Workspace-scoped issues (e.g. a root file that failed to load).
79 /// Per-node diagnostics live inside each `ConfigNodeView.validation`.
80 pub diagnostics: Vec<Diagnostic>,
81}
82
83impl WorkspaceSnapshot {
84 pub fn empty() -> Self {
85 Self {
86 files: Vec::new(),
87 routes: RouteCatalogSnapshot::empty(),
88 diagnostics: Vec::new(),
89 }
90 }
91}
92
93/// One TOML file inside the workspace.
94#[derive(Clone, Debug, Serialize)]
95#[non_exhaustive]
96pub struct ConfigFileView {
97 /// Absolute path on disk.
98 pub path: PathBuf,
99 /// Display name — typically the file name. Used as a tab title in
100 /// the GUI.
101 pub display_name: String,
102 /// What kind of file this is (root config, rule set, middleware).
103 pub kind: ConfigFileKind,
104 /// Editable nodes extracted from the file.
105 pub nodes: Vec<ConfigNodeView>,
106}
107
108#[derive(Clone, Copy, Debug, Serialize)]
109pub enum ConfigFileKind {
110 Root,
111 RuleSet,
112 Middleware,
113}
114
115/// One editable value inside a `ConfigFileView`.
116///
117/// Each node carries the six fields spec §4.2 makes mandatory.
118#[derive(Clone, Debug, Serialize)]
119#[non_exhaustive]
120pub struct ConfigNodeView {
121 /// Stable identifier — survives moves / renames within a Workspace
122 /// instance.
123 pub id: NodeId,
124 /// File the node was loaded from.
125 pub source_file: PathBuf,
126 /// Dotted TOML path inside `source_file` (e.g. `"listener.port"`,
127 /// `"rules[2].respond"`).
128 pub toml_path: String,
129 /// Human-readable label for UI list rendering (e.g. the rule's
130 /// `url_path` value, or `"Rule #3"` for a rule without one).
131 pub display_name: String,
132 /// Shape of the underlying value.
133 pub kind: NodeKind,
134 /// Per-node validation results.
135 pub validation: NodeValidation,
136}
137
138/// What shape of value a node holds. The variants are what the
139/// spec-defined `EditCommand` variants act on.
140#[derive(Clone, Copy, Debug, Serialize)]
141pub enum NodeKind {
142 /// Root config node — listener / log / service fields.
143 RootSetting,
144 /// One rule set loaded from a referenced TOML file.
145 RuleSet,
146 /// One rule inside a rule set.
147 Rule,
148 /// The `respond` block of a rule.
149 Respond,
150 /// File-based response node (fallback dir entry).
151 FileNode,
152 /// Script / middleware route.
153 Script,
154}
155
156/// Per-node validation result.
157///
158/// # Why validation is a field on the node and not a separate pass
159///
160/// GUIs render validation inline ("this field has a red underline").
161/// Keeping the validation result stapled to the node the GUI is about
162/// to render avoids a second lookup step in every render frame.
163#[derive(Clone, Debug, Default, Serialize)]
164pub struct NodeValidation {
165 /// Convenience flag — true iff `issues` is empty.
166 pub ok: bool,
167 /// Human-readable issues scoped to this node.
168 pub issues: Vec<ValidationIssue>,
169}
170
171impl NodeValidation {
172 pub fn ok() -> Self {
173 Self {
174 ok: true,
175 issues: Vec::new(),
176 }
177 }
178}
179
180#[derive(Clone, Debug, Serialize)]
181pub struct ValidationIssue {
182 pub severity: Severity,
183 pub message: String,
184}
185
186/// Structured edit command applied via `Workspace::apply`.
187///
188/// # Shape comes straight from spec §4.3
189///
190/// Each variant targets a node by NodeId (never by positional index).
191/// This guarantees edits remain well-defined across previous inserts /
192/// removes in the same GUI session.
193#[derive(Clone, Debug)]
194#[non_exhaustive]
195pub enum EditCommand {
196 /// Add a rule set file to the workspace.
197 ///
198 /// `path` is relative to the root config's directory — the same
199 /// convention as the value stored in `service.rule_sets`.
200 AddRuleSet {
201 path: String,
202 },
203 /// Remove a rule set by its NodeId. The underlying TOML file is
204 /// NOT deleted from disk — the workspace only removes the reference.
205 RemoveRuleSet {
206 id: NodeId,
207 },
208 /// Add a rule to an existing rule set.
209 AddRule {
210 parent: NodeId,
211 rule: RulePayload,
212 },
213 /// Update a rule's `when` / `respond` block.
214 ///
215 /// # Preservation of unspecified fields
216 ///
217 /// `RulePayload` carries `url_path`, `method`, and `respond` —
218 /// the fields a stage-1 GUI form exposes. A rule may also carry
219 /// `headers` and `body.json` match conditions that aren't part of
220 /// the payload shape. Those clauses are **preserved** across an
221 /// `UpdateRule`: the new rule keeps whatever headers / body
222 /// conditions the previous rule had, even though the payload
223 /// doesn't mention them.
224 ///
225 /// Without this preservation, every `UpdateRule` would silently
226 /// strip the unsurfaced clauses, which is a save-time bug when a
227 /// GUI re-saves a rule it loaded from a hand-edited TOML file.
228 UpdateRule {
229 id: NodeId,
230 rule: RulePayload,
231 },
232 /// Remove a rule by NodeId.
233 DeleteRule {
234 id: NodeId,
235 },
236 /// Reorder a rule within its parent rule set.
237 MoveRule {
238 id: NodeId,
239 new_index: usize,
240 },
241 /// Update the `respond` block of a rule.
242 UpdateRespond {
243 id: NodeId,
244 respond: RespondPayload,
245 },
246 /// Update a root-level setting (listener, log, service-level flags).
247 UpdateRootSetting {
248 key: RootSettingKey,
249 value: EditValue,
250 },
251}
252
253/// Payload for `AddRule` / `UpdateRule`.
254#[derive(Clone, Debug, Default)]
255pub struct RulePayload {
256 pub url_path: Option<String>,
257 pub method: Option<String>,
258 pub respond: RespondPayload,
259}
260
261/// Payload for `UpdateRespond`.
262///
263/// The three fields are mutually specialised: exactly one of
264/// `file_path` / `text` / `status` should be populated. Validation
265/// catches cases that violate this.
266#[derive(Clone, Debug, Default)]
267pub struct RespondPayload {
268 pub file_path: Option<String>,
269 pub text: Option<String>,
270 pub status: Option<u16>,
271 pub delay_milliseconds: Option<u32>,
272}
273
274/// Enumerated root-level setting. Typed enum rather than free-form
275/// path so the apply-layer can exhaustively match without parsing.
276#[derive(Clone, Copy, Debug)]
277#[non_exhaustive]
278pub enum RootSettingKey {
279 ListenerIpAddress,
280 ListenerPort,
281 ServiceFallbackRespondDir,
282 ServiceStrategy,
283}
284
285/// Value provided with an edit command.
286#[derive(Clone, Debug)]
287#[non_exhaustive]
288pub enum EditValue {
289 String(String),
290 Integer(i64),
291 Boolean(bool),
292 StringList(Vec<String>),
293 /// For settings whose domain is a small enum value (e.g.
294 /// `ServiceStrategy` → `"first_match"`).
295 Enum(String),
296 /// For completeness — callers can pass a raw JSON value when the
297 /// spec-defined key set is extended by stage-3 tooling. Currently
298 /// reserved; no stage-1 setting uses it.
299 Json(JsonValue),
300}
301
302/// Outcome of a successful `apply`.
303#[derive(Clone, Debug, Serialize)]
304#[non_exhaustive]
305pub struct ApplyResult {
306 /// Node IDs whose content (or position) changed.
307 pub changed_nodes: Vec<NodeId>,
308 /// Issues surfaced by applying the command (validation during apply
309 /// may add diagnostics — e.g. a new rule pointing at a missing file).
310 pub diagnostics: Vec<Diagnostic>,
311 /// `true` iff the server should reload to see this change. An edit
312 /// that changes the listener port needs a restart, not just a
313 /// reload — see `Workspace::save` for the richer `ReloadHint`.
314 pub requires_reload: bool,
315}
316
317/// Outcome of `Workspace::save`.
318#[derive(Clone, Debug, Serialize)]
319#[non_exhaustive]
320pub struct SaveResult {
321 /// TOML files actually written to disk.
322 pub changed_files: Vec<PathBuf>,
323 /// One entry per node that changed since last load.
324 pub diff_summary: Vec<DiffItem>,
325 pub requires_reload: bool,
326}
327
328/// One summary row in a `SaveResult::diff_summary`.
329#[derive(Clone, Debug, Serialize)]
330pub struct DiffItem {
331 pub kind: DiffKind,
332 pub target: NodeId,
333 pub summary: String,
334}
335
336#[derive(Clone, Copy, Debug, Serialize)]
337pub enum DiffKind {
338 Added,
339 Updated,
340 Removed,
341}
342
343/// Workspace-wide validation result. Mirrors spec §4.6.
344#[derive(Clone, Debug, Serialize)]
345pub struct ValidationReport {
346 pub diagnostics: Vec<Diagnostic>,
347 pub is_valid: bool,
348}
349
350impl ValidationReport {
351 pub fn ok() -> Self {
352 Self {
353 diagnostics: Vec::new(),
354 is_valid: true,
355 }
356 }
357}
358
359/// Human-readable notice about the workspace.
360#[derive(Clone, Debug, Serialize)]
361pub struct Diagnostic {
362 /// Target node, if any. `None` means "workspace-wide".
363 pub node_id: Option<NodeId>,
364 /// Target file, if the diagnostic is best reported at file level
365 /// (e.g. "could not read apimock-rule-set.toml"). May be `None` for
366 /// purely in-memory errors.
367 pub file: Option<PathBuf>,
368 pub severity: Severity,
369 pub message: String,
370}
371
372#[derive(Clone, Copy, Debug, Serialize)]
373pub enum Severity {
374 Error,
375 Warning,
376 Info,
377}
378
379// ---------------------------------------------------------------------------
380// Reload hint — spec §9. The same enum shape was defined in 5.0.0;
381// 5.1 reuses it unchanged so existing consumers keep working.
382// ---------------------------------------------------------------------------
383
384/// Advisory indicating what, if anything, the server needs to do in
385/// response to a config change.
386#[derive(Clone, Copy, Debug, Default, Serialize)]
387pub struct ReloadHint {
388 pub requires_reload: bool,
389 pub requires_restart: bool,
390}
391
392impl ReloadHint {
393 pub fn none() -> Self {
394 Self::default()
395 }
396
397 pub fn reload() -> Self {
398 Self {
399 requires_reload: true,
400 requires_restart: false,
401 }
402 }
403
404 pub fn restart() -> Self {
405 Self {
406 requires_reload: false,
407 requires_restart: true,
408 }
409 }
410}