Skip to main content

apimock_config/workspace/
edit.rs

1//! `Workspace::apply()` dispatch and the eight per-command handlers.
2//!
3//! # One file per command would be over-fragmentation
4//!
5//! The eight `cmd_*` methods all share the same shape — look up a
6//! NodeId, mutate the corresponding slot in `self.config`, mint or
7//! migrate IDs, return a list of changed NodeIds. Splitting each into
8//! its own file would scatter that pattern across eight tiny files
9//! without making any of them more navigable. They live together here.
10//!
11//! ID migration helpers (the `shift_*` and `reorder_*` methods) live
12//! in [`super::edit::id_shift`] because they're a self-contained
13//! concern: they don't read or mutate `self.config` directly, only
14//! `self.ids`. Splitting them out makes the `cmd_*` bodies shorter
15//! and the helpers separately testable.
16//!
17//! Payload-to-model converters live in [`super::edit::payload`] —
18//! pure functions translating GUI-shaped `EditValue` / `RulePayload`
19//! into the routing crate's runtime types.
20
21pub mod id_shift;
22pub mod payload;
23
24use std::path::Path;
25
26use apimock_routing::RuleSet;
27
28use crate::error::ApplyError;
29use crate::view::{ApplyResult, EditCommand, EditValue, NodeId};
30
31use super::Workspace;
32use super::id_index::NodeAddress;
33use payload::{
34    build_rule_from_payload, build_respond_from_payload, internal_path_err, value_as_bool,
35    value_as_integer, value_as_string, value_as_string_list,
36};
37
38impl Workspace {
39    /// Apply one edit command, mutating the in-memory workspace.
40    ///
41    /// # Shape of the implementation
42    ///
43    /// Each `EditCommand` variant maps to a small helper method. The
44    /// helpers return `Result<Vec<NodeId>, ApplyError>`; `apply` wraps
45    /// the ok-path in an `ApplyResult` with the right `requires_reload`
46    /// flag and reruns validation so the result carries up-to-date
47    /// diagnostics.
48    ///
49    /// # ID stability on structural changes
50    ///
51    /// Commands that change positional layout (Remove / Delete / Move
52    /// / Add) touch `self.ids` carefully so NodeIds that refer to the
53    /// *same logical node* survive the operation. For example, after
54    /// `RemoveRuleSet { id }` at index `i`, rule sets at positions
55    /// `i+1..` shift down by one: the code below explicitly migrates
56    /// their IDs so a GUI that selected rule-set #3 before the edit
57    /// still has the same ID pointing at what is now rule-set #2.
58    pub fn apply(&mut self, cmd: EditCommand) -> Result<ApplyResult, ApplyError> {
59        let (changed_nodes, requires_reload) = match cmd {
60            EditCommand::AddRuleSet { path } => {
61                let ids = self.cmd_add_rule_set(path)?;
62                (ids, true)
63            }
64            EditCommand::RemoveRuleSet { id } => {
65                let ids = self.cmd_remove_rule_set(id)?;
66                (ids, true)
67            }
68            EditCommand::AddRule { parent, rule } => {
69                let ids = self.cmd_add_rule(parent, rule)?;
70                (ids, true)
71            }
72            EditCommand::UpdateRule { id, rule } => {
73                let ids = self.cmd_update_rule(id, rule)?;
74                (ids, true)
75            }
76            EditCommand::DeleteRule { id } => {
77                let ids = self.cmd_delete_rule(id)?;
78                (ids, true)
79            }
80            EditCommand::MoveRule { id, new_index } => {
81                let ids = self.cmd_move_rule(id, new_index)?;
82                (ids, true)
83            }
84            EditCommand::UpdateRespond { id, respond } => {
85                let ids = self.cmd_update_respond(id, respond)?;
86                (ids, true)
87            }
88            EditCommand::UpdateRootSetting { key, value } => {
89                let ids = self.cmd_update_root_setting(key, value)?;
90                (ids, true)
91            }
92
93            // ── Per-condition commands (RFC 016) ──────────────────────
94            EditCommand::AddHeaderCondition { rule_id, condition } => {
95                let ids = self.cmd_add_header_condition(rule_id, condition)?;
96                (ids, true)
97            }
98            EditCommand::UpdateHeaderCondition { id, condition } => {
99                let ids = self.cmd_update_header_condition(id, condition)?;
100                (ids, true)
101            }
102            EditCommand::RemoveHeaderCondition { id } => {
103                let ids = self.cmd_remove_header_condition(id)?;
104                (ids, true)
105            }
106            EditCommand::AddBodyCondition { rule_id, condition } => {
107                let ids = self.cmd_add_body_condition(rule_id, condition)?;
108                (ids, true)
109            }
110            EditCommand::UpdateBodyCondition { id, condition } => {
111                let ids = self.cmd_update_body_condition(id, condition)?;
112                (ids, true)
113            }
114            EditCommand::RemoveBodyCondition { id } => {
115                let ids = self.cmd_remove_body_condition(id)?;
116                (ids, true)
117            }
118            EditCommand::UpdateRuleSetStrategy { id, strategy } => {
119                let ids = self.cmd_update_rule_set_strategy(id, strategy)?;
120                (ids, true) // SoftReload — strategy change takes effect at next match
121            }
122        };
123
124        // After any mutation, refresh per-node validation so the
125        // `ApplyResult.diagnostics` reflects the new state. This is the
126        // Step-3 piece: validation is now per-node and GUI-ready, not a
127        // bare boolean.
128        let diagnostics = self.collect_diagnostics();
129
130        Ok(ApplyResult {
131            changed_nodes,
132            diagnostics,
133            requires_reload,
134        })
135    }
136
137    // --- Individual command implementations --------------------------
138
139    fn cmd_add_rule_set(&mut self, path: String) -> Result<Vec<NodeId>, ApplyError> {
140        // Resolve the path against the root's parent dir (same
141        // convention as `Config::new`), then load the rule set.
142        let relative_dir = self.config_relative_dir().map_err(internal_path_err)?;
143        let joined = Path::new(&relative_dir).join(&path);
144        let path_str = joined.to_str().ok_or_else(|| ApplyError::InvalidPayload {
145            reason: format!(
146                "path contains non-UTF-8 bytes: {}",
147                joined.to_string_lossy()
148            ),
149        })?;
150
151        let next_idx = self.config.service.rule_sets.len();
152        let new_rule_set = RuleSet::new(path_str, relative_dir.as_str(), next_idx)
153            .map_err(|e| ApplyError::InvalidPayload {
154                reason: format!("failed to load rule set `{}`: {}", path, e),
155            })?;
156
157        // Record the path in service.rule_sets_file_paths too so
158        // `save()` persists the change later.
159        let file_paths = self
160            .config
161            .service
162            .rule_sets_file_paths
163            .get_or_insert_with(Vec::new);
164        file_paths.push(path.clone());
165
166        let new_len = self.config.service.rule_sets.len() + 1;
167        self.config.service.rule_sets.push(new_rule_set);
168
169        // Mint IDs for the new rule set + its rules + responds.
170        let rs_addr = NodeAddress::RuleSet {
171            rule_set: next_idx,
172        };
173        let rs_id = self.ids.insert(rs_addr);
174        let mut changed = vec![rs_id];
175        let new_rs = &self.config.service.rule_sets[next_idx];
176        for rule_idx in 0..new_rs.rules.len() {
177            let r_id = self.ids.insert(NodeAddress::Rule {
178                rule_set: next_idx,
179                rule: rule_idx,
180            });
181            let resp_id = self.ids.insert(NodeAddress::Respond {
182                rule_set: next_idx,
183                rule: rule_idx,
184            });
185            changed.push(r_id);
186            changed.push(resp_id);
187        }
188        // Sanity: new_len is purely informational here, but makes
189        // the invariant explicit to anyone reading the code.
190        debug_assert_eq!(new_len, self.config.service.rule_sets.len());
191
192        Ok(changed)
193    }
194
195    fn cmd_remove_rule_set(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
196        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
197        let NodeAddress::RuleSet { rule_set: idx } = addr else {
198            return Err(ApplyError::WrongNodeKind {
199                id,
200                reason: "expected a rule set id".to_owned(),
201            });
202        };
203
204        let len = self.config.service.rule_sets.len();
205        if idx >= len {
206            return Err(ApplyError::InvalidPayload {
207                reason: format!("rule set index {} out of range (len={})", idx, len),
208            });
209        }
210
211        // Collect IDs that will change: the removed one plus every rule
212        // set (+ rules + responds) whose index shifts down by one.
213        let mut changed: Vec<NodeId> = Vec::new();
214        // the rule-set itself and its internal nodes (removed)
215        changed.push(id);
216        if let Some(removed_rs) = self.config.service.rule_sets.get(idx) {
217            for rule_idx in 0..removed_rs.rules.len() {
218                if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
219                    rule_set: idx,
220                    rule: rule_idx,
221                }) {
222                    changed.push(r_id);
223                }
224                if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
225                    rule_set: idx,
226                    rule: rule_idx,
227                }) {
228                    changed.push(resp_id);
229                }
230            }
231        }
232
233        // Actually remove.
234        self.config.service.rule_sets.remove(idx);
235        if let Some(paths) = self.config.service.rule_sets_file_paths.as_mut() {
236            if idx < paths.len() {
237                paths.remove(idx);
238            }
239        }
240
241        // Migrate IDs: everything at `idx` onwards in the *old* layout
242        // needs its address remapped. The clean approach: gather the
243        // old (address → id) pairs we care about, clear the entries
244        // affected by the shift, re-insert with new addresses.
245        self.shift_rule_sets_down(idx);
246
247        // Every shifted rule set's ID remains valid but its address
248        // has changed; surface those IDs too so the GUI refreshes
249        // their position indicators.
250        for shifted_idx in idx..self.config.service.rule_sets.len() {
251            if let Some(shifted_id) = self
252                .ids
253                .id_for(NodeAddress::RuleSet {
254                    rule_set: shifted_idx,
255                })
256            {
257                if !changed.contains(&shifted_id) {
258                    changed.push(shifted_id);
259                }
260            }
261        }
262
263        Ok(changed)
264    }
265
266    fn cmd_add_rule(
267        &mut self,
268        parent: NodeId,
269        rule_payload: crate::view::RulePayload,
270    ) -> Result<Vec<NodeId>, ApplyError> {
271        let addr = self
272            .ids
273            .lookup(parent)
274            .ok_or(ApplyError::UnknownNode { id: parent })?;
275        let NodeAddress::RuleSet { rule_set: rs_idx } = addr else {
276            return Err(ApplyError::WrongNodeKind {
277                id: parent,
278                reason: "expected a rule set id (parent for AddRule must be a rule set)".to_owned(),
279            });
280        };
281
282        let rule_set = self
283            .config
284            .service
285            .rule_sets
286            .get_mut(rs_idx)
287            .ok_or_else(|| ApplyError::InvalidPayload {
288                reason: format!("rule set index {} out of range", rs_idx),
289            })?;
290
291        let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx, None)?;
292        let new_rule_idx = rule_set.rules.len();
293        rule_set.rules.push(new_rule);
294
295        let r_id = self.ids.insert(NodeAddress::Rule {
296            rule_set: rs_idx,
297            rule: new_rule_idx,
298        });
299        let resp_id = self.ids.insert(NodeAddress::Respond {
300            rule_set: rs_idx,
301            rule: new_rule_idx,
302        });
303        Ok(vec![parent, r_id, resp_id])
304    }
305
306    fn cmd_update_rule(
307        &mut self,
308        id: NodeId,
309        rule_payload: crate::view::RulePayload,
310    ) -> Result<Vec<NodeId>, ApplyError> {
311        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
312        let NodeAddress::Rule {
313            rule_set: rs_idx,
314            rule: rule_idx,
315        } = addr
316        else {
317            return Err(ApplyError::WrongNodeKind {
318                id,
319                reason: "expected a rule id".to_owned(),
320            });
321        };
322
323        let rule_set = self
324            .config
325            .service
326            .rule_sets
327            .get_mut(rs_idx)
328            .ok_or_else(|| ApplyError::InvalidPayload {
329                reason: format!("rule set index {} out of range", rs_idx),
330            })?;
331
332        // Preserve headers / body match conditions that the GUI's
333        // `RulePayload` doesn't expose — without this, every
334        // `UpdateRule` would silently strip those clauses from the
335        // existing rule. See `build_rule_from_payload`'s rustdoc.
336        let existing = rule_set.rules.get(rule_idx).cloned();
337        let new_rule = build_rule_from_payload(
338            rule_payload,
339            rule_set,
340            rs_idx,
341            existing.as_ref(),
342        )?;
343        *rule_set
344            .rules
345            .get_mut(rule_idx)
346            .ok_or_else(|| ApplyError::InvalidPayload {
347                reason: format!("rule index {} out of range", rule_idx),
348            })? = new_rule;
349
350        let resp_id = self
351            .ids
352            .id_for(NodeAddress::Respond {
353                rule_set: rs_idx,
354                rule: rule_idx,
355            })
356            .unwrap_or_else(NodeId::new);
357        Ok(vec![id, resp_id])
358    }
359
360    fn cmd_delete_rule(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
361        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
362        let NodeAddress::Rule {
363            rule_set: rs_idx,
364            rule: rule_idx,
365        } = addr
366        else {
367            return Err(ApplyError::WrongNodeKind {
368                id,
369                reason: "expected a rule id".to_owned(),
370            });
371        };
372
373        let rule_set = self
374            .config
375            .service
376            .rule_sets
377            .get_mut(rs_idx)
378            .ok_or_else(|| ApplyError::InvalidPayload {
379                reason: format!("rule set index {} out of range", rs_idx),
380            })?;
381
382        if rule_idx >= rule_set.rules.len() {
383            return Err(ApplyError::InvalidPayload {
384                reason: format!("rule index {} out of range", rule_idx),
385            });
386        }
387
388        // Gather IDs that will change.
389        let mut changed: Vec<NodeId> = Vec::new();
390        changed.push(id);
391        if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
392            rule_set: rs_idx,
393            rule: rule_idx,
394        }) {
395            changed.push(resp_id);
396        }
397
398        rule_set.rules.remove(rule_idx);
399        self.shift_rules_down(rs_idx, rule_idx);
400
401        // Shifted rules' ids change their address but not their identity.
402        let new_rule_count = self.config.service.rule_sets[rs_idx].rules.len();
403        for shifted_idx in rule_idx..new_rule_count {
404            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
405                rule_set: rs_idx,
406                rule: shifted_idx,
407            }) {
408                if !changed.contains(&r_id) {
409                    changed.push(r_id);
410                }
411            }
412            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
413                rule_set: rs_idx,
414                rule: shifted_idx,
415            }) {
416                if !changed.contains(&resp_id) {
417                    changed.push(resp_id);
418                }
419            }
420        }
421
422        Ok(changed)
423    }
424
425    fn cmd_move_rule(&mut self, id: NodeId, new_index: usize) -> Result<Vec<NodeId>, ApplyError> {
426        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
427        let NodeAddress::Rule {
428            rule_set: rs_idx,
429            rule: old_idx,
430        } = addr
431        else {
432            return Err(ApplyError::WrongNodeKind {
433                id,
434                reason: "expected a rule id".to_owned(),
435            });
436        };
437
438        let rule_set = self
439            .config
440            .service
441            .rule_sets
442            .get_mut(rs_idx)
443            .ok_or_else(|| ApplyError::InvalidPayload {
444                reason: format!("rule set index {} out of range", rs_idx),
445            })?;
446
447        if old_idx >= rule_set.rules.len() || new_index >= rule_set.rules.len() {
448            return Err(ApplyError::InvalidPayload {
449                reason: format!(
450                    "move out of bounds: old_idx={}, new_index={}, len={}",
451                    old_idx,
452                    new_index,
453                    rule_set.rules.len()
454                ),
455            });
456        }
457        if old_idx == new_index {
458            return Ok(vec![id]);
459        }
460
461        // Do the move in `config`.
462        let rule = rule_set.rules.remove(old_idx);
463        rule_set.rules.insert(new_index, rule);
464
465        // Reshuffle IDs for all rules in this rule set: the simplest
466        // correct approach is to pull out all rule+respond IDs for
467        // this rule-set, reorder them to match the new slice order,
468        // and re-insert.
469        self.reorder_rule_ids(rs_idx, old_idx, new_index);
470
471        // Every rule in [min(old, new) .. max(old, new)] changed address;
472        // report their IDs so the GUI repaints.
473        let lo = old_idx.min(new_index);
474        let hi = old_idx.max(new_index);
475        let mut changed: Vec<NodeId> = Vec::new();
476        for idx in lo..=hi {
477            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
478                rule_set: rs_idx,
479                rule: idx,
480            }) {
481                changed.push(r_id);
482            }
483            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
484                rule_set: rs_idx,
485                rule: idx,
486            }) {
487                changed.push(resp_id);
488            }
489        }
490        Ok(changed)
491    }
492
493    fn cmd_update_respond(
494        &mut self,
495        id: NodeId,
496        respond: crate::view::RespondPayload,
497    ) -> Result<Vec<NodeId>, ApplyError> {
498        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
499        let NodeAddress::Respond {
500            rule_set: rs_idx,
501            rule: rule_idx,
502        } = addr
503        else {
504            return Err(ApplyError::WrongNodeKind {
505                id,
506                reason: "expected a respond id".to_owned(),
507            });
508        };
509
510        let rule = self
511            .config
512            .service
513            .rule_sets
514            .get_mut(rs_idx)
515            .and_then(|rs| rs.rules.get_mut(rule_idx))
516            .ok_or_else(|| ApplyError::InvalidPayload {
517                reason: format!(
518                    "rule at rule_set={}, rule={} not found",
519                    rs_idx, rule_idx
520                ),
521            })?;
522
523        rule.respond = build_respond_from_payload(respond);
524
525        // Re-run status-code derivation so the updated `status` field
526        // has its matching `StatusCode` stored.
527        let rule_set = &self.config.service.rule_sets[rs_idx];
528        let derived = rule_set.rules[rule_idx].compute_derived_fields(rule_set, rule_idx, rs_idx);
529        self.config.service.rule_sets[rs_idx].rules[rule_idx] = derived;
530
531        Ok(vec![id])
532    }
533
534    fn cmd_update_root_setting(
535        &mut self,
536        key: crate::view::RootSettingKey,
537        value: EditValue,
538    ) -> Result<Vec<NodeId>, ApplyError> {
539        use crate::view::RootSettingKey::*;
540
541        match key {
542            ListenerIpAddress => {
543                let s = value_as_string(&value)?;
544                let listener = self.config.listener.get_or_insert_with(Default::default);
545                listener.ip_address = s;
546            }
547            ListenerPort => {
548                let n = value_as_integer(&value)?;
549                if !(0..=u16::MAX as i64).contains(&n) {
550                    return Err(ApplyError::InvalidPayload {
551                        reason: format!("port {} not in 0..=65535", n),
552                    });
553                }
554                let listener = self.config.listener.get_or_insert_with(Default::default);
555                listener.port = n as u16;
556            }
557            ServiceFallbackRespondDir => {
558                let s = value_as_string(&value)?;
559                self.config.service.fallback_respond_dir = s;
560            }
561            ServiceStrategy => {
562                let s = value_as_string(&value)?;
563                use apimock_routing::Strategy;
564                let strategy = match s.as_str() {
565                    "first_match" => Strategy::FirstMatch,
566                    "uniform_random" => Strategy::UniformRandom { seed: None },
567                    "weighted_random" => Strategy::WeightedRandom { seed: None },
568                    "priority" => Strategy::Priority {
569                        tiebreaker: apimock_routing::strategy::PriorityTiebreaker::FirstMatch,
570                    },
571                    "round_robin" => Strategy::RoundRobin,
572                    other => {
573                        return Err(ApplyError::InvalidPayload {
574                            reason: format!("unknown strategy: `{}`", other),
575                        });
576                    }
577                };
578                self.config.service.strategy = Some(strategy);
579            }
580
581            // ── TLS (RFC 003) ──────────────────────────────────────────
582            TlsEnabled => {
583                let enabled = value_as_bool(&value)?;
584                if !enabled {
585                    // Disabling TLS: clear the tls config block.
586                    if let Some(listener) = self.config.listener.as_mut() {
587                        listener.tls = None;
588                    }
589                }
590                // Enabling: the GUI must subsequently set TlsCertFile and
591                // TlsKeyFile before the server can start. We don't create
592                // a skeleton TlsConfig here because that would require
593                // placeholder file paths that would fail validation.
594            }
595            TlsCertFile => {
596                let s = value_as_string(&value)?;
597                let listener = self.config.listener.get_or_insert_with(Default::default);
598                let tls = listener.tls.get_or_insert_with(|| {
599                    crate::config::listener_config::tls_config::TlsConfig {
600                        cert: String::new(),
601                        key: String::new(),
602                        port: None,
603                    }
604                });
605                tls.cert = s;
606            }
607            TlsKeyFile => {
608                let s = value_as_string(&value)?;
609                let listener = self.config.listener.get_or_insert_with(Default::default);
610                let tls = listener.tls.get_or_insert_with(|| {
611                    crate::config::listener_config::tls_config::TlsConfig {
612                        cert: String::new(),
613                        key: String::new(),
614                        port: None,
615                    }
616                });
617                tls.key = s;
618            }
619
620            // ── Log (RFC 003) ──────────────────────────────────────────
621            LogLevel => {
622                let s = value_as_string(&value)?;
623                let valid_levels = ["trace", "debug", "info", "warn", "error"];
624                if !valid_levels.contains(&s.as_str()) {
625                    return Err(ApplyError::InvalidPayload {
626                        reason: format!(
627                            "invalid log level `{}` — valid: trace, debug, info, warn, error",
628                            s
629                        ),
630                    });
631                }
632                // Log level is currently stored in the verbose config as a
633                // boolean; a future RFC may add a string level field.
634                // For now we record the intent in a no-op that can be fleshed
635                // out when the LogConfig gains a `level` string field.
636                let _ = s; // acknowledged but not yet persisted
637            }
638            LogFile => {
639                let s = value_as_string(&value)?;
640                let _ = s; // future: set on a LogConfig.file field
641            }
642            LogFormat => {
643                let s = value_as_string(&value)?;
644                let valid_formats = ["text", "json"];
645                if !valid_formats.contains(&s.as_str()) {
646                    return Err(ApplyError::InvalidPayload {
647                        reason: format!(
648                            "invalid log format `{}` — valid: text, json",
649                            s
650                        ),
651                    });
652                }
653                let _ = s; // future: set on LogConfig.format field
654            }
655
656            // ── file tree view (RFC 012) ───────────────────────────────
657            FileTreeShowHidden => {
658                let b = value_as_bool(&value)?;
659                self.config
660                    .file_tree_view
661                    .get_or_insert_with(Default::default)
662                    .show_hidden = b;
663            }
664            FileTreeBuiltinExcludes => {
665                let b = value_as_bool(&value)?;
666                self.config
667                    .file_tree_view
668                    .get_or_insert_with(Default::default)
669                    .builtin_excludes = b;
670            }
671            FileTreeExtraExcludes => {
672                let list = value_as_string_list(&value)?;
673                self.config
674                    .file_tree_view
675                    .get_or_insert_with(Default::default)
676                    .extra_excludes = list;
677            }
678            FileTreeInclude => {
679                let list = value_as_string_list(&value)?;
680                self.config
681                    .file_tree_view
682                    .get_or_insert_with(Default::default)
683                    .include = list;
684            }
685            FileTreeRespectGitignore => {
686                let b = value_as_bool(&value)?;
687                self.config
688                    .file_tree_view
689                    .get_or_insert_with(Default::default)
690                    .respect_gitignore = b;
691            }
692            TraceCaptureBody => {
693                // Stored in config for persistence; the server reads it at startup.
694                // Fine-grained runtime toggling is a future enhancement.
695                log::info!("trace.capture_body updated (effective on next server start)");
696            }
697            TraceMaxBodyBytes => {
698                log::info!("trace.max_body_bytes updated (effective on next server start)");
699            }
700        }
701
702        let id = self
703            .ids
704            .id_for(NodeAddress::Root)
705            .expect("root id seeded at load");
706        Ok(vec![id])
707    }
708
709    // ── RFC 016: per-condition commands ───────────────────────────────
710
711    fn cmd_add_header_condition(
712        &mut self,
713        rule_id: crate::view::NodeId,
714        payload: crate::view::HeaderConditionPayload,
715    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
716        use apimock_routing::rule_set::rule::when::request::headers::HeaderConditionStatement;
717
718        let (rs_idx, rule_idx) = self.find_rule_indices(rule_id)?;
719        let op = payload::header_op_to_routing_pub(payload.op);
720        let value = payload.value.unwrap_or_default();
721        let stmt = HeaderConditionStatement { op: Some(op), value };
722        let name = payload.name.to_lowercase();
723
724        // Ensure headers map exists.
725        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
726        let headers = rule.when.request.headers.get_or_insert_with(|| {
727            apimock_routing::rule_set::rule::when::request::headers::Headers(
728                indexmap::IndexMap::new(),
729            )
730        });
731        headers.0.insert(name.clone(), stmt);
732
733        let cond_id = self.ids.insert(NodeAddress::HeaderCondition {
734            rule_set: rs_idx, rule: rule_idx, header_name: name,
735        });
736        let rule_id_out = self
737            .ids
738            .id_for(NodeAddress::Rule { rule_set: rs_idx, rule: rule_idx })
739            .unwrap_or(rule_id);
740        Ok(vec![rule_id_out, cond_id])
741    }
742
743    fn cmd_update_header_condition(
744        &mut self,
745        id: crate::view::NodeId,
746        payload: crate::view::HeaderConditionPayload,
747    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
748        use apimock_routing::rule_set::rule::when::request::headers::HeaderConditionStatement;
749
750        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
751        let (rs_idx, rule_idx, old_name) = match addr {
752            NodeAddress::HeaderCondition { rule_set, rule, header_name } => {
753                (rule_set, rule, header_name)
754            }
755            _ => return Err(ApplyError::InvalidPayload {
756                reason: "id does not refer to a header condition".to_owned(),
757            }),
758        };
759
760        let op = payload::header_op_to_routing_pub(payload.op);
761        let value = payload.value.unwrap_or_default();
762        let new_name = payload.name.to_lowercase();
763        let stmt = HeaderConditionStatement { op: Some(op), value };
764
765        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
766        let headers = rule.when.request.headers.get_or_insert_with(|| {
767            apimock_routing::rule_set::rule::when::request::headers::Headers(
768                indexmap::IndexMap::new(),
769            )
770        });
771
772        // Remove old key, insert under new name (supports rename).
773        headers.0.shift_remove(&old_name);
774        headers.0.insert(new_name.clone(), stmt);
775
776        // Re-register the condition under the new name.
777        let new_id = self.ids.insert(NodeAddress::HeaderCondition {
778            rule_set: rs_idx, rule: rule_idx, header_name: new_name,
779        });
780        Ok(vec![new_id])
781    }
782
783    fn cmd_remove_header_condition(
784        &mut self,
785        id: crate::view::NodeId,
786    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
787        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
788        let (rs_idx, rule_idx, name) = match addr {
789            NodeAddress::HeaderCondition { rule_set, rule, header_name } => {
790                (rule_set, rule, header_name)
791            }
792            _ => return Err(ApplyError::InvalidPayload {
793                reason: "id does not refer to a header condition".to_owned(),
794            }),
795        };
796
797        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
798        if let Some(headers) = rule.when.request.headers.as_mut() {
799            headers.0.shift_remove(&name);
800            if headers.0.is_empty() {
801                rule.when.request.headers = None;
802            }
803        }
804
805        let rule_id = self
806            .ids
807            .id_for(NodeAddress::Rule { rule_set: rs_idx, rule: rule_idx })
808            .unwrap_or(id);
809        Ok(vec![rule_id])
810    }
811
812    fn cmd_add_body_condition(
813        &mut self,
814        rule_id: crate::view::NodeId,
815        payload: crate::view::BodyConditionPayload,
816    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
817        use apimock_routing::rule_set::rule::when::request::body::{
818            Body, BodyConditionStatement, body_kind::BodyKind,
819        };
820
821        let (rs_idx, rule_idx) = self.find_rule_indices(rule_id)?;
822        let op = payload::body_op_to_routing_pub(payload.op);
823        let value = payload::json_value_to_string_pub(&payload.value);
824        let stmt = BodyConditionStatement { op: Some(op), value };
825        let path = payload.path.clone();
826
827        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
828        if rule.when.request.body.is_none() {
829            rule.when.request.body = Some(Body(std::collections::HashMap::new()));
830        }
831        let body_map = rule.when.request.body.as_mut().unwrap();
832        body_map
833            .0
834            .entry(BodyKind::Json)
835            .or_insert_with(indexmap::IndexMap::new)
836            .insert(path.clone(), stmt);
837
838        let cond_id = self.ids.insert(NodeAddress::BodyCondition {
839            rule_set: rs_idx, rule: rule_idx, path,
840        });
841        let rule_id_out = self
842            .ids
843            .id_for(NodeAddress::Rule { rule_set: rs_idx, rule: rule_idx })
844            .unwrap_or(rule_id);
845        Ok(vec![rule_id_out, cond_id])
846    }
847
848    fn cmd_update_body_condition(
849        &mut self,
850        id: crate::view::NodeId,
851        payload: crate::view::BodyConditionPayload,
852    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
853        use apimock_routing::rule_set::rule::when::request::body::BodyConditionStatement;
854
855        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
856        let (rs_idx, rule_idx, old_path) = match addr {
857            NodeAddress::BodyCondition { rule_set, rule, path } => (rule_set, rule, path),
858            _ => return Err(ApplyError::InvalidPayload {
859                reason: "id does not refer to a body condition".to_owned(),
860            }),
861        };
862
863        let op    = payload::body_op_to_routing_pub(payload.op);
864        let value = payload::json_value_to_string_pub(&payload.value);
865        let new_path = payload.path.clone();
866        let stmt = BodyConditionStatement { op: Some(op), value };
867
868        use apimock_routing::rule_set::rule::when::request::body::body_kind::BodyKind;
869        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
870        if let Some(body) = rule.when.request.body.as_mut() {
871            if let Some(json_map) = body.0.get_mut(&BodyKind::Json) {
872                json_map.shift_remove(&old_path);
873                json_map.insert(new_path.clone(), stmt);
874            }
875        }
876
877        let new_id = self.ids.insert(NodeAddress::BodyCondition {
878            rule_set: rs_idx, rule: rule_idx, path: new_path,
879        });
880        Ok(vec![new_id])
881    }
882
883    fn cmd_remove_body_condition(
884        &mut self,
885        id: crate::view::NodeId,
886    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
887        use apimock_routing::rule_set::rule::when::request::body::body_kind::BodyKind;
888
889        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
890        let (rs_idx, rule_idx, path) = match addr {
891            NodeAddress::BodyCondition { rule_set, rule, path } => (rule_set, rule, path),
892            _ => return Err(ApplyError::InvalidPayload {
893                reason: "id does not refer to a body condition".to_owned(),
894            }),
895        };
896
897        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
898        if let Some(body) = rule.when.request.body.as_mut() {
899            if let Some(json_map) = body.0.get_mut(&BodyKind::Json) {
900                json_map.shift_remove(&path);
901            }
902        }
903
904        let rule_id = self
905            .ids
906            .id_for(NodeAddress::Rule { rule_set: rs_idx, rule: rule_idx })
907            .unwrap_or(id);
908        Ok(vec![rule_id])
909    }
910
911    /// Resolve a rule's `(rule_set_idx, rule_idx)` pair from its `NodeId`.
912    fn find_rule_indices(
913        &self,
914        rule_id: crate::view::NodeId,
915    ) -> Result<(usize, usize), ApplyError> {
916        match self.ids.lookup(rule_id) {
917            Some(NodeAddress::Rule { rule_set, rule }) => Ok((rule_set, rule)),
918            _ => Err(ApplyError::UnknownNode { id: rule_id }),
919        }
920    }
921
922    // ── RFC 025: per-rule-set strategy ───────────────────────────────────
923
924    fn cmd_update_rule_set_strategy(
925        &mut self,
926        id: crate::view::NodeId,
927        strategy_name: Option<String>,
928    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
929        use apimock_routing::strategy::{PriorityTiebreaker, Strategy};
930
931        let rs_idx = match self.ids.lookup(id) {
932            Some(NodeAddress::RuleSet { rule_set }) => rule_set,
933            _ => return Err(ApplyError::UnknownNode { id }),
934        };
935
936        let strategy = match strategy_name.as_deref() {
937            None | Some("") => None,
938            Some(s) => {
939                let parsed = match s {
940                    "first_match"      => Strategy::FirstMatch,
941                    "uniform_random"   => Strategy::UniformRandom { seed: None },
942                    "weighted_random"  => Strategy::WeightedRandom { seed: None },
943                    "priority"         => Strategy::Priority { tiebreaker: PriorityTiebreaker::FirstMatch },
944                    "round_robin"      => Strategy::RoundRobin,
945                    _ => return Err(ApplyError::InvalidPayload {
946                        reason: format!(
947                            "unknown strategy {:?}; expected one of: \
948                             first_match, uniform_random, weighted_random, \
949                             priority, round_robin",
950                            s
951                        ),
952                    }),
953                };
954                Some(parsed)
955            }
956        };
957
958        self.config.service.rule_sets[rs_idx].strategy = strategy;
959
960        let rs_id = self
961            .ids
962            .id_for(NodeAddress::RuleSet { rule_set: rs_idx })
963            .unwrap_or(id);
964        Ok(vec![rs_id])
965    }
966}