Skip to main content

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