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 { path: String },
201 /// Remove a rule set by its NodeId. The underlying TOML file is
202 /// NOT deleted from disk — the workspace only removes the reference.
203 RemoveRuleSet { id: NodeId },
204 /// Add a rule to an existing rule set.
205 AddRule { parent: NodeId, rule: RulePayload },
206 /// Update a rule's `when` / `respond` block.
207 ///
208 /// # Preservation of unspecified fields
209 ///
210 /// `RulePayload` carries `url_path`, `method`, and `respond` —
211 /// the fields a stage-1 GUI form exposes. A rule may also carry
212 /// `headers` and `body.json` match conditions that aren't part of
213 /// the payload shape. Those clauses are **preserved** across an
214 /// `UpdateRule`: the new rule keeps whatever headers / body
215 /// conditions the previous rule had, even though the payload
216 /// doesn't mention them.
217 ///
218 /// Without this preservation, every `UpdateRule` would silently
219 /// strip the unsurfaced clauses, which is a save-time bug when a
220 /// GUI re-saves a rule it loaded from a hand-edited TOML file.
221 UpdateRule { id: NodeId, rule: RulePayload },
222 /// Remove a rule by NodeId.
223 DeleteRule { id: NodeId },
224 /// Reorder a rule within its parent rule set.
225 MoveRule { id: NodeId, new_index: usize },
226 /// Update the `respond` block of a rule.
227 UpdateRespond { id: NodeId, respond: RespondPayload },
228 /// Update a root-level setting (listener, log, service-level flags).
229 UpdateRootSetting {
230 key: RootSettingKey,
231 value: EditValue,
232 },
233
234 // ── Per-condition commands (RFC 016) ──────────────────────────────
235 /// Add a single header condition to an existing rule.
236 ///
237 /// `rule_id` must be the `NodeId` of the target rule.
238 AddHeaderCondition {
239 rule_id: NodeId,
240 condition: HeaderConditionPayload,
241 },
242 /// Replace a header condition in-place, identified by its `NodeId`.
243 ///
244 /// The header name (`condition.name`) may differ from the original —
245 /// this counts as a rename, which reassigns the condition's `NodeId`.
246 UpdateHeaderCondition {
247 id: NodeId,
248 condition: HeaderConditionPayload,
249 },
250 /// Remove a single header condition by its `NodeId`.
251 RemoveHeaderCondition { id: NodeId },
252 /// Add a single body condition to an existing rule.
253 AddBodyCondition {
254 rule_id: NodeId,
255 condition: BodyConditionPayload,
256 },
257 /// Replace a body condition in-place, identified by its `NodeId`.
258 UpdateBodyCondition {
259 id: NodeId,
260 condition: BodyConditionPayload,
261 },
262 /// Remove a single body condition by its `NodeId`.
263 RemoveBodyCondition { id: NodeId },
264
265 // ── Per-rule-set settings (RFC 025) ──────────────────────────────
266 /// Override the strategy for a specific rule set.
267 ///
268 /// `strategy` is the `snake_case` strategy name (e.g. `"round_robin"`,
269 /// `"first_match"`). Pass `None` to remove the override and inherit
270 /// the service-level strategy.
271 UpdateRuleSetStrategy {
272 id: NodeId,
273 strategy: Option<String>,
274 },
275}
276
277/// Stable identity for one condition, assigned at snapshot time.
278///
279/// Returned by [`Workspace::snapshot`] alongside each condition view so
280/// GUI code can target granular edit commands without reading index
281/// positions.
282#[derive(Clone, Debug)]
283pub struct ConditionWithId<V> {
284 pub id: NodeId,
285 pub view: V,
286}
287///
288/// # Preservation of unspecified fields (5.5.0 guarantee)
289///
290/// Fields set to `None` are preserved from the existing rule when this
291/// is an `UpdateRule` call. The `headers` and `body` fields use
292/// `Option<Vec<_>>` to distinguish three states:
293/// - `None` — preserve existing conditions.
294/// - `Some(vec![])` — clear all conditions.
295/// - `Some(vec![…])` — replace with the given set.
296///
297/// # URL path operator (RFC 001)
298///
299/// `url_path_op` controls which operator the routing crate uses to
300/// match the given `url_path` value. When `url_path_op` is `None` and
301/// `url_path` is `Some(_)`, the operator defaults to `Equal` (5.7.0
302/// behaviour). When `url_path` is `None`, both fields are ignored.
303///
304/// # Header and body conditions (RFC 002)
305///
306/// `headers` and `body` are optional lists of conditions. Each `None`
307/// preserves the existing rule's conditions; each `Some(_)` replaces
308/// them wholesale (an empty `Vec` clears them).
309#[derive(Clone, Debug, Default)]
310pub struct RulePayload {
311 pub url_path: Option<String>,
312 /// URL path match operator (RFC 001). `None` defaults to `Equal`.
313 pub url_path_op: Option<UrlPathOp>,
314 pub method: Option<String>,
315 /// Priority for the `Priority` strategy (RFC 027). `None` = 0.
316 pub priority: Option<i32>,
317 /// Header conditions (RFC 002). `None` = preserve; `Some([])` = clear.
318 pub headers: Option<Vec<HeaderConditionPayload>>,
319 /// Body conditions (RFC 002). `None` = preserve; `Some([])` = clear.
320 pub body: Option<Vec<BodyConditionPayload>>,
321 pub respond: RespondPayload,
322}
323
324// ── RFC 001 — URL path operator ───────────────────────────────────────
325
326/// Operator for the URL path match in [`RulePayload`].
327///
328/// Mirrors the routing crate's internal operator set but lives in
329/// `apimock-config` to decouple the GUI-facing payload type from
330/// routing-internal types.
331#[derive(Clone, Copy, Debug, PartialEq, Eq)]
332pub enum UrlPathOp {
333 Equal,
334 StartsWith,
335 NotStartsWith,
336 EndsWith,
337 NotEndsWith,
338 Contains,
339 NotContains,
340 /// Glob wildcard match.
341 WildCard,
342 /// Negated equality match.
343 NotEqual,
344 /// Regular expression match (RFC 017).
345 Regex,
346 /// Inverse regular expression match (RFC 021).
347 NotRegex,
348}
349
350// ── RFC 002 — Header and body condition payloads ──────────────────────
351
352/// One header condition in a [`RulePayload`].
353#[derive(Clone, Debug)]
354pub struct HeaderConditionPayload {
355 /// Header name (case-insensitive at match time).
356 pub name: String,
357 pub op: HeaderOp,
358 /// Required for all operators except `Exists` / `Absent`.
359 pub value: Option<String>,
360}
361
362/// Operator for a header condition.
363#[derive(Clone, Copy, Debug, PartialEq, Eq)]
364pub enum HeaderOp {
365 Equal,
366 Contains,
367 NotContains,
368 StartsWith,
369 NotStartsWith,
370 EndsWith,
371 NotEndsWith,
372 Regex,
373 NotRegex,
374 /// Header must be present (any value).
375 Exists,
376 /// Header must be absent.
377 Absent,
378 NotEqual,
379 WildCard,
380}
381
382/// One body condition in a [`RulePayload`].
383#[derive(Clone, Debug)]
384pub struct BodyConditionPayload {
385 /// Currently only `Json`.
386 pub kind: BodyConditionKind,
387 /// Dotted path into the JSON body (not canonical JSONPath).
388 pub path: String,
389 pub op: BodyOp,
390 /// Configured comparison value.
391 pub value: serde_json::Value,
392}
393
394/// Body condition kind — currently only JSON.
395#[derive(Clone, Copy, Debug, PartialEq, Eq)]
396pub enum BodyConditionKind {
397 Json,
398}
399
400/// Operator for a body condition (RFC 002 / RFC 008 / RFC 021 / RFC 022).
401#[derive(Clone, Copy, Debug, PartialEq, Eq)]
402pub enum BodyOp {
403 // string-style
404 Equal,
405 EqualString,
406 Contains,
407 NotContains,
408 StartsWith,
409 NotStartsWith,
410 EndsWith,
411 NotEndsWith,
412 Regex,
413 NotRegex,
414 // type-aware
415 EqualTyped,
416 // numeric
417 EqualNumber,
418 GreaterThan,
419 LessThan,
420 GreaterOrEqual,
421 LessOrEqual,
422 // presence
423 Exists,
424 Absent,
425 // array
426 ArrayLengthEqual,
427 ArrayLengthAtLeast,
428 ArrayContains,
429 // exact integer (RFC 010)
430 EqualInteger,
431 // map/object (RFC 022)
432 MapHasKey,
433 MapDoesNotHaveKey,
434 // structural (RFC 028)
435 StructuralContains,
436}
437
438/// Payload for `UpdateRespond`.
439///
440/// The three fields are mutually specialised: exactly one of
441/// `file_path` / `text` / `status` should be populated. Validation
442/// catches cases that violate this.
443#[derive(Clone, Debug, Default)]
444pub struct RespondPayload {
445 pub file_path: Option<String>,
446 pub text: Option<String>,
447 pub status: Option<u16>,
448 pub delay_milliseconds: Option<u32>,
449}
450
451/// Enumerated root-level setting. Typed enum rather than free-form
452/// path so the apply-layer can exhaustively match without parsing.
453///
454/// # RFC 003 — TLS and Log variants
455///
456/// Seven new variants cover TLS configuration and log settings.
457/// Changes to TLS and listener fields require a full process restart
458/// (`HardRestart`); log-level and strategy changes only need a soft
459/// config reload (`SoftReload`).
460#[derive(Clone, Copy, Debug)]
461#[non_exhaustive]
462pub enum RootSettingKey {
463 // ── listener ──────────────────────────────────────────────────────
464 ListenerIpAddress,
465 ListenerPort,
466 // ── service ───────────────────────────────────────────────────────
467 ServiceFallbackRespondDir,
468 ServiceStrategy,
469 // ── TLS (RFC 003) ─────────────────────────────────────────────────
470 TlsEnabled,
471 TlsCertFile,
472 TlsKeyFile,
473 // ── log (RFC 003) ─────────────────────────────────────────────────
474 LogLevel,
475 LogFile,
476 LogFormat,
477 // ── file tree view (RFC 012 / RFC 019) ───────────────────────────────
478 FileTreeShowHidden,
479 FileTreeBuiltinExcludes,
480 /// Value: `EditValue::StringList`
481 FileTreeExtraExcludes,
482 /// Value: `EditValue::StringList`
483 FileTreeInclude,
484 /// Value: `EditValue::Boolean` (RFC 019)
485 FileTreeRespectGitignore,
486 // ── trace (RFC 023) ─────────────────────────────────────────────
487 /// Capture JSON request body in trace events. Value: `EditValue::Boolean`.
488 TraceCaptureBody,
489 /// Max body size in bytes for trace capture. Value: `EditValue::Integer`.
490 TraceMaxBodyBytes,
491}
492
493/// Value provided with an edit command.
494#[derive(Clone, Debug)]
495#[non_exhaustive]
496pub enum EditValue {
497 String(String),
498 Integer(i64),
499 Boolean(bool),
500 StringList(Vec<String>),
501 /// For settings whose domain is a small enum value (e.g.
502 /// `ServiceStrategy` → `"first_match"`).
503 Enum(String),
504 /// For completeness — callers can pass a raw JSON value when the
505 /// spec-defined key set is extended by stage-3 tooling. Currently
506 /// reserved; no stage-1 setting uses it.
507 Json(JsonValue),
508}
509
510/// Outcome of a successful `apply`.
511#[derive(Clone, Debug, Serialize)]
512#[non_exhaustive]
513pub struct ApplyResult {
514 /// Node IDs whose content (or position) changed.
515 pub changed_nodes: Vec<NodeId>,
516 /// Issues surfaced by applying the command (validation during apply
517 /// may add diagnostics — e.g. a new rule pointing at a missing file).
518 pub diagnostics: Vec<Diagnostic>,
519 /// `true` iff the server should reload to see this change. An edit
520 /// that changes the listener port needs a restart, not just a
521 /// reload — see `Workspace::save` for the richer `ReloadHint`.
522 pub requires_reload: bool,
523}
524
525/// Outcome of `Workspace::save`.
526#[derive(Clone, Debug, Serialize)]
527#[non_exhaustive]
528pub struct SaveResult {
529 /// TOML files actually written to disk.
530 pub changed_files: Vec<PathBuf>,
531 /// One entry per node that changed since last load.
532 pub diff_summary: Vec<DiffItem>,
533 pub requires_reload: bool,
534}
535
536/// One summary row in a `SaveResult::diff_summary`.
537#[derive(Clone, Debug, Serialize)]
538pub struct DiffItem {
539 pub kind: DiffKind,
540 pub target: NodeId,
541 pub summary: String,
542}
543
544#[derive(Clone, Copy, Debug, Serialize)]
545pub enum DiffKind {
546 Added,
547 Updated,
548 Removed,
549 /// A header condition was added to an existing rule (RFC 029).
550 HeaderConditionAdded,
551 /// A header condition was removed from an existing rule (RFC 029).
552 HeaderConditionRemoved,
553 /// A body condition was added to an existing rule (RFC 029).
554 BodyConditionAdded,
555 /// A body condition was removed from an existing rule (RFC 029).
556 BodyConditionRemoved,
557}
558
559/// Workspace-wide validation result. Mirrors spec §4.6.
560#[derive(Clone, Debug, Serialize)]
561pub struct ValidationReport {
562 pub diagnostics: Vec<Diagnostic>,
563 pub is_valid: bool,
564}
565
566impl ValidationReport {
567 pub fn ok() -> Self {
568 Self {
569 diagnostics: Vec::new(),
570 is_valid: true,
571 }
572 }
573}
574
575/// Human-readable notice about the workspace.
576#[derive(Clone, Debug, Serialize)]
577pub struct Diagnostic {
578 /// Target node, if any. `None` means "workspace-wide".
579 pub node_id: Option<NodeId>,
580 /// Target file, if the diagnostic is best reported at file level
581 /// (e.g. "could not read apimock-rule-set.toml"). May be `None` for
582 /// purely in-memory errors.
583 pub file: Option<PathBuf>,
584 pub severity: Severity,
585 pub message: String,
586}
587
588#[derive(Clone, Copy, Debug, Serialize)]
589pub enum Severity {
590 Error,
591 Warning,
592 Info,
593}
594
595// ---------------------------------------------------------------------------
596// Reload hint — spec §9. The same enum shape was defined in 5.0.0;
597// 5.1 reuses it unchanged so existing consumers keep working.
598// ---------------------------------------------------------------------------
599
600/// Advisory indicating what, if anything, the server needs to do in
601/// response to a config change.
602///
603/// # RFC 003 — Reload semantics
604///
605/// | Key group | Hint |
606/// |-------------------------------|----------------|
607/// | `ListenerIpAddress/Port` | `HardRestart` |
608/// | `TlsEnabled`, `TlsCert*` | `HardRestart` |
609/// | `LogFile` | `HardRestart` |
610/// | `ServiceFallbackRespondDir` | `SoftReload` |
611/// | `ServiceStrategy` | `SoftReload` |
612/// | `LogLevel`, `LogFormat` | `SoftReload` |
613///
614/// The hint is advisory — the server does not auto-restart. The GUI
615/// surfaces it to the user.
616#[derive(Clone, Copy, Debug, Default, Serialize)]
617pub struct ReloadHint {
618 /// Server can re-read config without rebinding the listener.
619 pub requires_reload: bool,
620 /// Process must restart (rebind socket, reload TLS, reopen log file).
621 pub requires_restart: bool,
622}
623
624impl ReloadHint {
625 pub fn none() -> Self {
626 Self::default()
627 }
628
629 /// Config can be hot-reloaded without a process restart.
630 pub fn reload() -> Self {
631 Self {
632 requires_reload: true,
633 requires_restart: false,
634 }
635 }
636
637 /// Process must fully restart for this change to take effect.
638 pub fn restart() -> Self {
639 Self {
640 requires_reload: false,
641 requires_restart: true,
642 }
643 }
644
645 /// Return the hint appropriate for the given [`RootSettingKey`].
646 pub fn for_key(key: RootSettingKey) -> Self {
647 use RootSettingKey::*;
648 match key {
649 // Listener rebind required — new socket, new TLS stack setup.
650 ListenerIpAddress | ListenerPort | TlsEnabled | LogFile => Self::restart(),
651 // RFC 020: cert/key rotation uses the reloadable resolver — no rebind.
652 TlsCertFile | TlsKeyFile => Self::reload(),
653 ServiceFallbackRespondDir
654 | ServiceStrategy
655 | LogLevel
656 | LogFormat
657 | FileTreeShowHidden
658 | FileTreeBuiltinExcludes
659 | FileTreeExtraExcludes
660 | FileTreeInclude
661 | FileTreeRespectGitignore
662 | TraceCaptureBody
663 | TraceMaxBodyBytes => Self::reload(),
664 }
665 }
666}