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)]
46#[non_exhaustive]
47pub struct NodeId(pub Uuid);
48
49impl NodeId {
50    pub fn new() -> Self {
51        Self(Uuid::new_v4())
52    }
53}
54
55impl Default for NodeId {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl std::fmt::Display for NodeId {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        self.0.fmt(f)
64    }
65}
66
67/// Complete snapshot of the workspace state.
68///
69/// Shape matches spec §4.2 exactly. Consumed read-only by the GUI;
70/// mutated indirectly via `Workspace::apply`.
71#[derive(Clone, Debug, Serialize)]
72#[non_exhaustive]
73pub struct WorkspaceSnapshot {
74    /// All editable TOML files in the workspace, flattened. Each file
75    /// carries its own list of editable nodes.
76    pub files: Vec<ConfigFileView>,
77    /// Route overview pulled from the routing crate.
78    pub routes: RouteCatalogSnapshot,
79    /// Workspace-scoped issues (e.g. a root file that failed to load).
80    /// Per-node diagnostics live inside each `ConfigNodeView.validation`.
81    pub diagnostics: Vec<Diagnostic>,
82}
83
84impl WorkspaceSnapshot {
85    pub fn empty() -> Self {
86        Self {
87            files: Vec::new(),
88            routes: RouteCatalogSnapshot::empty(),
89            diagnostics: Vec::new(),
90        }
91    }
92}
93
94/// One TOML file inside the workspace.
95#[derive(Clone, Debug, Serialize)]
96#[non_exhaustive]
97pub struct ConfigFileView {
98    /// Absolute path on disk.
99    pub path: PathBuf,
100    /// Display name — typically the file name. Used as a tab title in
101    /// the GUI.
102    pub display_name: String,
103    /// What kind of file this is (root config, rule set, middleware).
104    pub kind: ConfigFileKind,
105    /// Editable nodes extracted from the file.
106    pub nodes: Vec<ConfigNodeView>,
107}
108
109#[derive(Clone, Copy, Debug, Serialize)]
110#[non_exhaustive]
111pub enum ConfigFileKind {
112    Root,
113    RuleSet,
114    Middleware,
115}
116
117/// One editable value inside a `ConfigFileView`.
118///
119/// Each node carries the six fields spec §4.2 makes mandatory.
120#[derive(Clone, Debug, Serialize)]
121#[non_exhaustive]
122pub struct ConfigNodeView {
123    /// Stable identifier — survives moves / renames within a Workspace
124    /// instance.
125    pub id: NodeId,
126    /// File the node was loaded from.
127    pub source_file: PathBuf,
128    /// Dotted TOML path inside `source_file` (e.g. `"listener.port"`,
129    /// `"rules[2].respond"`).
130    pub toml_path: String,
131    /// Human-readable label for UI list rendering (e.g. the rule's
132    /// `url_path` value, or `"Rule #3"` for a rule without one).
133    pub display_name: String,
134    /// Shape of the underlying value.
135    pub kind: NodeKind,
136    /// Per-node validation results.
137    pub validation: NodeValidation,
138}
139
140/// What shape of value a node holds. The variants are what the
141/// spec-defined `EditCommand` variants act on.
142#[derive(Clone, Copy, Debug, Serialize)]
143#[non_exhaustive]
144pub enum NodeKind {
145    /// Root config node — listener / log / service fields.
146    RootSetting,
147    /// One rule set loaded from a referenced TOML file.
148    RuleSet,
149    /// One rule inside a rule set.
150    Rule,
151    /// The `respond` block of a rule.
152    Respond,
153    /// File-based response node (fallback dir entry).
154    FileNode,
155    /// Script / middleware route.
156    Script,
157}
158
159/// Per-node validation result.
160///
161/// # Why validation is a field on the node and not a separate pass
162///
163/// GUIs render validation inline ("this field has a red underline").
164/// Keeping the validation result stapled to the node the GUI is about
165/// to render avoids a second lookup step in every render frame.
166#[derive(Clone, Debug, Default, Serialize)]
167#[non_exhaustive]
168pub struct NodeValidation {
169    /// Convenience flag — true iff `issues` is empty.
170    pub ok: bool,
171    /// Human-readable issues scoped to this node.
172    pub issues: Vec<ValidationIssue>,
173}
174
175impl NodeValidation {
176    pub fn ok() -> Self {
177        Self {
178            ok: true,
179            issues: Vec::new(),
180        }
181    }
182}
183
184#[derive(Clone, Debug, Serialize)]
185#[non_exhaustive]
186pub struct ValidationIssue {
187    pub severity: Severity,
188    pub message: String,
189}
190
191impl ValidationIssue {
192    pub fn new(severity: Severity, message: impl Into<String>) -> Self {
193        Self {
194            severity,
195            message: message.into(),
196        }
197    }
198}
199
200/// Structured edit command applied via `Workspace::apply`.
201///
202/// # Shape comes straight from spec §4.3
203///
204/// Each variant targets a node by NodeId (never by positional index).
205/// This guarantees edits remain well-defined across previous inserts /
206/// removes in the same GUI session.
207#[derive(Clone, Debug)]
208#[non_exhaustive]
209pub enum EditCommand {
210    /// Add a rule set file to the workspace.
211    ///
212    /// `path` is relative to the root config's directory — the same
213    /// convention as the value stored in `service.rule_sets`.
214    AddRuleSet { path: String },
215    /// Remove a rule set by its NodeId. The underlying TOML file is
216    /// NOT deleted from disk — the workspace only removes the reference.
217    RemoveRuleSet { id: NodeId },
218    /// Add a rule to an existing rule set.
219    AddRule { parent: NodeId, rule: RulePayload },
220    /// Update a rule's `when` / `respond` block.
221    ///
222    /// # Preservation of unspecified fields
223    ///
224    /// `RulePayload` carries `url_path`, `method`, and `respond` —
225    /// the fields a stage-1 GUI form exposes. A rule may also carry
226    /// `headers` and `body.json` match conditions that aren't part of
227    /// the payload shape. Those clauses are **preserved** across an
228    /// `UpdateRule`: the new rule keeps whatever headers / body
229    /// conditions the previous rule had, even though the payload
230    /// doesn't mention them.
231    ///
232    /// Without this preservation, every `UpdateRule` would silently
233    /// strip the unsurfaced clauses, which is a save-time bug when a
234    /// GUI re-saves a rule it loaded from a hand-edited TOML file.
235    UpdateRule { id: NodeId, rule: RulePayload },
236    /// Remove a rule by NodeId.
237    DeleteRule { id: NodeId },
238    /// Reorder a rule within its parent rule set.
239    MoveRule { id: NodeId, new_index: usize },
240    /// Update the `respond` block of a rule.
241    UpdateRespond { id: NodeId, respond: RespondPayload },
242    /// Update a root-level setting (listener, log, service-level flags).
243    UpdateRootSetting {
244        key: RootSettingKey,
245        value: EditValue,
246    },
247
248    // ── Per-condition commands (RFC 016) ──────────────────────────────
249    /// Add a single header condition to an existing rule.
250    ///
251    /// `rule_id` must be the `NodeId` of the target rule.
252    AddHeaderCondition {
253        rule_id: NodeId,
254        condition: HeaderConditionPayload,
255    },
256    /// Replace a header condition in-place, identified by its `NodeId`.
257    ///
258    /// The header name (`condition.name`) may differ from the original —
259    /// this counts as a rename, which reassigns the condition's `NodeId`.
260    UpdateHeaderCondition {
261        id: NodeId,
262        condition: HeaderConditionPayload,
263    },
264    /// Remove a single header condition by its `NodeId`.
265    RemoveHeaderCondition { id: NodeId },
266    /// Add a single body condition to an existing rule.
267    AddBodyCondition {
268        rule_id: NodeId,
269        condition: BodyConditionPayload,
270    },
271    /// Replace a body condition in-place, identified by its `NodeId`.
272    UpdateBodyCondition {
273        id: NodeId,
274        condition: BodyConditionPayload,
275    },
276    /// Remove a single body condition by its `NodeId`.
277    RemoveBodyCondition { id: NodeId },
278
279    // ── Per-rule-set settings (RFC 025) ──────────────────────────────
280    /// Override the strategy for a specific rule set.
281    ///
282    /// `strategy` is the `snake_case` strategy name (e.g. `"round_robin"`,
283    /// `"first_match"`). Pass `None` to remove the override and inherit
284    /// the service-level strategy.
285    UpdateRuleSetStrategy {
286        id: NodeId,
287        strategy: Option<String>,
288    },
289}
290
291/// Stable identity for one condition, assigned at snapshot time.
292///
293/// Returned by [`Workspace::snapshot`] alongside each condition view so
294/// GUI code can target granular edit commands without reading index
295/// positions.
296#[derive(Clone, Debug)]
297#[non_exhaustive]
298pub struct ConditionWithId<V> {
299    pub id: NodeId,
300    pub view: V,
301}
302
303impl<V> ConditionWithId<V> {
304    pub fn new(id: NodeId, view: V) -> Self {
305        Self { id, view }
306    }
307}
308///
309/// # Preservation of unspecified fields (5.5.0 guarantee)
310///
311/// Fields set to `None` are preserved from the existing rule when this
312/// is an `UpdateRule` call. The `headers` and `body` fields use
313/// `Option<Vec<_>>` to distinguish three states:
314/// - `None` — preserve existing conditions.
315/// - `Some(vec![])` — clear all conditions.
316/// - `Some(vec![…])` — replace with the given set.
317///
318/// # URL path operator (RFC 001)
319///
320/// `url_path_op` controls which operator the routing crate uses to
321/// match the given `url_path` value. When `url_path_op` is `None` and
322/// `url_path` is `Some(_)`, the operator defaults to `Equal` (5.7.0
323/// behaviour). When `url_path` is `None`, both fields are ignored.
324///
325/// # Header and body conditions (RFC 002)
326///
327/// `headers` and `body` are optional lists of conditions. Each `None`
328/// preserves the existing rule's conditions; each `Some(_)` replaces
329/// them wholesale (an empty `Vec` clears them).
330#[derive(Clone, Debug, Default)]
331#[non_exhaustive]
332pub struct RulePayload {
333    pub url_path: Option<String>,
334    /// URL path match operator (RFC 001). `None` defaults to `Equal`.
335    pub url_path_op: Option<UrlPathOp>,
336    pub method: Option<String>,
337    /// Priority for the `Priority` strategy (RFC 027). `None` = 0.
338    pub priority: Option<i32>,
339    /// Header conditions (RFC 002). `None` = preserve; `Some([])` = clear.
340    pub headers: Option<Vec<HeaderConditionPayload>>,
341    /// Body conditions (RFC 002). `None` = preserve; `Some([])` = clear.
342    pub body: Option<Vec<BodyConditionPayload>>,
343    pub respond: RespondPayload,
344}
345
346// ── RFC 001 — URL path operator ───────────────────────────────────────
347
348/// Operator for the URL path match in [`RulePayload`].
349///
350/// Mirrors the routing crate's internal operator set but lives in
351/// `apimock-config` to decouple the GUI-facing payload type from
352/// routing-internal types.
353#[derive(Clone, Copy, Debug, PartialEq, Eq)]
354#[non_exhaustive]
355pub enum UrlPathOp {
356    Equal,
357    StartsWith,
358    NotStartsWith,
359    EndsWith,
360    NotEndsWith,
361    Contains,
362    NotContains,
363    /// Glob wildcard match.
364    WildCard,
365    /// Negated equality match.
366    NotEqual,
367    /// Regular expression match (RFC 017).
368    Regex,
369    /// Inverse regular expression match (RFC 021).
370    NotRegex,
371}
372
373// ── RFC 002 — Header and body condition payloads ──────────────────────
374
375/// One header condition in a [`RulePayload`].
376///
377/// # `#[non_exhaustive]` (RFC 041)
378///
379/// ```compile_fail
380/// use apimock_config::{HeaderConditionPayload, HeaderOp};
381///
382/// let _ = HeaderConditionPayload {
383///     name: todo!(),
384///     op: HeaderOp::Equal,
385///     value: todo!(),
386/// };
387/// ```
388#[derive(Clone, Debug)]
389#[non_exhaustive]
390pub struct HeaderConditionPayload {
391    /// Header name (case-insensitive at match time).
392    pub name: String,
393    pub op: HeaderOp,
394    /// Required for all operators except `Exists` / `Absent`.
395    pub value: Option<String>,
396}
397
398impl HeaderConditionPayload {
399    /// `value` starts unset — required for all operators except
400    /// `Exists` / `Absent`; set it afterwards where needed.
401    pub fn new(name: impl Into<String>, op: HeaderOp) -> Self {
402        Self {
403            name: name.into(),
404            op,
405            value: None,
406        }
407    }
408}
409
410/// Operator for a header condition.
411#[derive(Clone, Copy, Debug, PartialEq, Eq)]
412#[non_exhaustive]
413pub enum HeaderOp {
414    Equal,
415    Contains,
416    NotContains,
417    StartsWith,
418    NotStartsWith,
419    EndsWith,
420    NotEndsWith,
421    Regex,
422    NotRegex,
423    /// Header must be present (any value).
424    Exists,
425    /// Header must be absent.
426    Absent,
427    NotEqual,
428    WildCard,
429}
430
431/// One body condition in a [`RulePayload`].
432#[derive(Clone, Debug)]
433#[non_exhaustive]
434pub struct BodyConditionPayload {
435    /// Currently only `Json`.
436    pub kind: BodyConditionKind,
437    /// Dotted path into the JSON body (not canonical JSONPath).
438    pub path: String,
439    pub op: BodyOp,
440    /// Configured comparison value.
441    pub value: serde_json::Value,
442}
443
444impl BodyConditionPayload {
445    pub fn new(
446        kind: BodyConditionKind,
447        path: impl Into<String>,
448        op: BodyOp,
449        value: serde_json::Value,
450    ) -> Self {
451        Self {
452            kind,
453            path: path.into(),
454            op,
455            value,
456        }
457    }
458}
459
460/// Body condition kind — currently only JSON.
461#[derive(Clone, Copy, Debug, PartialEq, Eq)]
462#[non_exhaustive]
463pub enum BodyConditionKind {
464    Json,
465}
466
467/// Operator for a body condition (RFC 002 / RFC 008 / RFC 021 / RFC 022).
468#[derive(Clone, Copy, Debug, PartialEq, Eq)]
469#[non_exhaustive]
470pub enum BodyOp {
471    // string-style
472    Equal,
473    EqualString,
474    Contains,
475    NotContains,
476    StartsWith,
477    NotStartsWith,
478    EndsWith,
479    NotEndsWith,
480    Regex,
481    NotRegex,
482    // type-aware
483    EqualTyped,
484    // numeric
485    EqualNumber,
486    GreaterThan,
487    LessThan,
488    GreaterOrEqual,
489    LessOrEqual,
490    // presence
491    Exists,
492    Absent,
493    // array
494    ArrayLengthEqual,
495    ArrayLengthAtLeast,
496    ArrayContains,
497    // exact integer (RFC 010)
498    EqualInteger,
499    // map/object (RFC 022)
500    MapHasKey,
501    MapDoesNotHaveKey,
502    // structural (RFC 028)
503    StructuralContains,
504}
505
506/// Payload for `UpdateRespond`.
507///
508/// `file_path` / `text` / `json` are mutually specialised (RFC 065):
509/// exactly one should be populated. Validation catches cases that
510/// violate this.
511#[derive(Clone, Debug, Default)]
512#[non_exhaustive]
513pub struct RespondPayload {
514    pub file_path: Option<String>,
515    pub text: Option<String>,
516    /// The response body, declared as JSON (RFC 065) — served as
517    /// `application/json` rather than `text`'s `text/plain`. Mutually
518    /// exclusive with `file_path` and `text`.
519    pub json: Option<String>,
520    pub status: Option<u16>,
521    pub delay_milliseconds: Option<u32>,
522}
523
524/// Enumerated root-level setting. Typed enum rather than free-form
525/// path so the apply-layer can exhaustively match without parsing.
526///
527/// # RFC 003 — TLS and Log variants
528///
529/// Seven new variants cover TLS configuration and log settings.
530/// Changes to TLS and listener fields require a full process restart
531/// (`HardRestart`); log-level and strategy changes only need a soft
532/// config reload (`SoftReload`).
533#[derive(Clone, Copy, Debug)]
534#[non_exhaustive]
535pub enum RootSettingKey {
536    // ── listener ──────────────────────────────────────────────────────
537    ListenerIpAddress,
538    ListenerPort,
539    // ── service ───────────────────────────────────────────────────────
540    ServiceFallbackRespondDir,
541    ServiceStrategy,
542    // ── TLS (RFC 003) ─────────────────────────────────────────────────
543    TlsEnabled,
544    TlsCertFile,
545    TlsKeyFile,
546    // ── log (RFC 003) ─────────────────────────────────────────────────
547    LogLevel,
548    LogFile,
549    LogFormat,
550    // ── file tree view (RFC 012 / RFC 019) ───────────────────────────────
551    FileTreeShowHidden,
552    FileTreeBuiltinExcludes,
553    /// Value: `EditValue::StringList`
554    FileTreeExtraExcludes,
555    /// Value: `EditValue::StringList`
556    FileTreeInclude,
557    /// Value: `EditValue::Boolean` (RFC 019)
558    FileTreeRespectGitignore,
559    // ── trace (RFC 023) ─────────────────────────────────────────────
560    /// Capture JSON request body in trace events. Value: `EditValue::Boolean`.
561    TraceCaptureBody,
562    /// Max body size in bytes for trace capture. Value: `EditValue::Integer`.
563    TraceMaxBodyBytes,
564}
565
566/// Value provided with an edit command.
567#[derive(Clone, Debug)]
568#[non_exhaustive]
569pub enum EditValue {
570    String(String),
571    Integer(i64),
572    Boolean(bool),
573    StringList(Vec<String>),
574    /// For settings whose domain is a small enum value (e.g.
575    /// `ServiceStrategy` → `"first_match"`).
576    Enum(String),
577    /// For completeness — callers can pass a raw JSON value when the
578    /// spec-defined key set is extended by stage-3 tooling. Currently
579    /// reserved; no stage-1 setting uses it.
580    Json(JsonValue),
581}
582
583/// Outcome of a successful `apply`.
584#[derive(Clone, Debug, Serialize)]
585#[non_exhaustive]
586pub struct ApplyResult {
587    /// Node IDs whose content (or position) changed.
588    pub changed_nodes: Vec<NodeId>,
589    /// Issues surfaced by applying the command (validation during apply
590    /// may add diagnostics — e.g. a new rule pointing at a missing file).
591    pub diagnostics: Vec<Diagnostic>,
592    /// `true` iff the server should reload to see this change. An edit
593    /// that changes the listener port needs a restart, not just a
594    /// reload — see `Workspace::save` for the richer `ReloadHint`.
595    pub requires_reload: bool,
596}
597
598/// Outcome of `Workspace::save`.
599#[derive(Clone, Debug, Serialize)]
600#[non_exhaustive]
601pub struct SaveResult {
602    /// TOML files actually written to disk.
603    pub changed_files: Vec<PathBuf>,
604    /// One entry per node that changed since last load.
605    pub diff_summary: Vec<DiffItem>,
606    pub requires_reload: bool,
607}
608
609/// One summary row in a `SaveResult::diff_summary`.
610#[derive(Clone, Debug, Serialize)]
611#[non_exhaustive]
612pub struct DiffItem {
613    pub kind: DiffKind,
614    pub target: NodeId,
615    pub summary: String,
616}
617
618impl DiffItem {
619    pub fn new(kind: DiffKind, target: NodeId, summary: impl Into<String>) -> Self {
620        Self {
621            kind,
622            target,
623            summary: summary.into(),
624        }
625    }
626}
627
628#[derive(Clone, Copy, Debug, Serialize)]
629#[non_exhaustive]
630pub enum DiffKind {
631    Added,
632    Updated,
633    Removed,
634    /// A header condition was added to an existing rule (RFC 029).
635    HeaderConditionAdded,
636    /// A header condition was removed from an existing rule (RFC 029).
637    HeaderConditionRemoved,
638    /// A body condition was added to an existing rule (RFC 029).
639    BodyConditionAdded,
640    /// A body condition was removed from an existing rule (RFC 029).
641    BodyConditionRemoved,
642}
643
644/// Workspace-wide validation result. Mirrors spec §4.6.
645#[derive(Clone, Debug, Serialize)]
646#[non_exhaustive]
647pub struct ValidationReport {
648    pub diagnostics: Vec<Diagnostic>,
649    pub is_valid: bool,
650}
651
652impl ValidationReport {
653    pub fn ok() -> Self {
654        Self {
655            diagnostics: Vec::new(),
656            is_valid: true,
657        }
658    }
659}
660
661/// `ValidationReport::ok()` is also the meaningful empty state: "no
662/// diagnostics gathered yet", the state a caller building one from
663/// outside the crate starts from before assigning its own fields.
664impl Default for ValidationReport {
665    fn default() -> Self {
666        Self::ok()
667    }
668}
669
670/// Human-readable notice about the workspace.
671#[derive(Clone, Debug, Serialize)]
672#[non_exhaustive]
673pub struct Diagnostic {
674    /// Target node, if any. `None` means "workspace-wide".
675    pub node_id: Option<NodeId>,
676    /// Target file, if the diagnostic is best reported at file level
677    /// (e.g. "could not read apimock-rule-set.toml"). May be `None` for
678    /// purely in-memory errors.
679    pub file: Option<PathBuf>,
680    pub severity: Severity,
681    pub message: String,
682}
683
684impl Diagnostic {
685    /// `node_id` and `file` start unset — assign them afterwards for a
686    /// diagnostic scoped to a node or a file.
687    pub fn new(severity: Severity, message: impl Into<String>) -> Self {
688        Self {
689            node_id: None,
690            file: None,
691            severity,
692            message: message.into(),
693        }
694    }
695}
696
697#[derive(Clone, Copy, Debug, Serialize)]
698#[non_exhaustive]
699pub enum Severity {
700    Error,
701    Warning,
702    Info,
703}
704
705// ---------------------------------------------------------------------------
706// Reload hint — spec §9. The same enum shape was defined in 5.0.0;
707// 5.1 reuses it unchanged so existing consumers keep working.
708// ---------------------------------------------------------------------------
709
710/// Advisory indicating what, if anything, the server needs to do in
711/// response to a config change.
712///
713/// # RFC 003 — Reload semantics
714///
715/// | Key group                     | Hint           |
716/// |-------------------------------|----------------|
717/// | `ListenerIpAddress/Port`      | `HardRestart`  |
718/// | `TlsEnabled`, `TlsCert*`      | `HardRestart`  |
719/// | `LogFile`                     | `HardRestart`  |
720/// | `ServiceFallbackRespondDir`   | `SoftReload`   |
721/// | `ServiceStrategy`             | `SoftReload`   |
722/// | `LogLevel`, `LogFormat`       | `SoftReload`   |
723///
724/// The hint is advisory — the server does not auto-restart. The GUI
725/// surfaces it to the user.
726#[derive(Clone, Copy, Debug, Default, Serialize)]
727#[non_exhaustive]
728pub struct ReloadHint {
729    /// Server can re-read config without rebinding the listener.
730    pub requires_reload: bool,
731    /// Process must restart (rebind socket, reload TLS, reopen log file).
732    pub requires_restart: bool,
733}
734
735impl ReloadHint {
736    pub fn none() -> Self {
737        Self::default()
738    }
739
740    /// Config can be hot-reloaded without a process restart.
741    pub fn reload() -> Self {
742        Self {
743            requires_reload: true,
744            requires_restart: false,
745        }
746    }
747
748    /// Process must fully restart for this change to take effect.
749    pub fn restart() -> Self {
750        Self {
751            requires_reload: false,
752            requires_restart: true,
753        }
754    }
755
756    /// Return the hint appropriate for the given [`RootSettingKey`].
757    pub fn for_key(key: RootSettingKey) -> Self {
758        use RootSettingKey::*;
759        match key {
760            // Listener rebind required — new socket, new TLS stack setup.
761            ListenerIpAddress | ListenerPort | TlsEnabled | LogFile => Self::restart(),
762            // RFC 020: cert/key rotation uses the reloadable resolver — no rebind.
763            TlsCertFile | TlsKeyFile => Self::reload(),
764            ServiceFallbackRespondDir
765            | ServiceStrategy
766            | LogLevel
767            | LogFormat
768            | FileTreeShowHidden
769            | FileTreeBuiltinExcludes
770            | FileTreeExtraExcludes
771            | FileTreeInclude
772            | FileTreeRespectGitignore
773            | TraceCaptureBody
774            | TraceMaxBodyBytes => Self::reload(),
775        }
776    }
777}