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        };
119
120        // After any mutation, refresh per-node validation so the
121        // `ApplyResult.diagnostics` reflects the new state. This is the
122        // Step-3 piece: validation is now per-node and GUI-ready, not a
123        // bare boolean.
124        let diagnostics = self.collect_diagnostics();
125
126        Ok(ApplyResult {
127            changed_nodes,
128            diagnostics,
129            requires_reload,
130        })
131    }
132
133    // --- Individual command implementations --------------------------
134
135    fn cmd_add_rule_set(&mut self, path: String) -> Result<Vec<NodeId>, ApplyError> {
136        // Resolve the path against the root's parent dir (same
137        // convention as `Config::new`), then load the rule set.
138        let relative_dir = self.config_relative_dir().map_err(internal_path_err)?;
139        let joined = Path::new(&relative_dir).join(&path);
140        let path_str = joined.to_str().ok_or_else(|| ApplyError::InvalidPayload {
141            reason: format!(
142                "path contains non-UTF-8 bytes: {}",
143                joined.to_string_lossy()
144            ),
145        })?;
146
147        let next_idx = self.config.service.rule_sets.len();
148        let new_rule_set = RuleSet::new(path_str, relative_dir.as_str(), next_idx)
149            .map_err(|e| ApplyError::InvalidPayload {
150                reason: format!("failed to load rule set `{}`: {}", path, e),
151            })?;
152
153        // Record the path in service.rule_sets_file_paths too so
154        // `save()` persists the change later.
155        let file_paths = self
156            .config
157            .service
158            .rule_sets_file_paths
159            .get_or_insert_with(Vec::new);
160        file_paths.push(path.clone());
161
162        let new_len = self.config.service.rule_sets.len() + 1;
163        self.config.service.rule_sets.push(new_rule_set);
164
165        // Mint IDs for the new rule set + its rules + responds.
166        let rs_addr = NodeAddress::RuleSet {
167            rule_set: next_idx,
168        };
169        let rs_id = self.ids.insert(rs_addr);
170        let mut changed = vec![rs_id];
171        let new_rs = &self.config.service.rule_sets[next_idx];
172        for rule_idx in 0..new_rs.rules.len() {
173            let r_id = self.ids.insert(NodeAddress::Rule {
174                rule_set: next_idx,
175                rule: rule_idx,
176            });
177            let resp_id = self.ids.insert(NodeAddress::Respond {
178                rule_set: next_idx,
179                rule: rule_idx,
180            });
181            changed.push(r_id);
182            changed.push(resp_id);
183        }
184        // Sanity: new_len is purely informational here, but makes
185        // the invariant explicit to anyone reading the code.
186        debug_assert_eq!(new_len, self.config.service.rule_sets.len());
187
188        Ok(changed)
189    }
190
191    fn cmd_remove_rule_set(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
192        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
193        let NodeAddress::RuleSet { rule_set: idx } = addr else {
194            return Err(ApplyError::WrongNodeKind {
195                id,
196                reason: "expected a rule set id".to_owned(),
197            });
198        };
199
200        let len = self.config.service.rule_sets.len();
201        if idx >= len {
202            return Err(ApplyError::InvalidPayload {
203                reason: format!("rule set index {} out of range (len={})", idx, len),
204            });
205        }
206
207        // Collect IDs that will change: the removed one plus every rule
208        // set (+ rules + responds) whose index shifts down by one.
209        let mut changed: Vec<NodeId> = Vec::new();
210        // the rule-set itself and its internal nodes (removed)
211        changed.push(id);
212        if let Some(removed_rs) = self.config.service.rule_sets.get(idx) {
213            for rule_idx in 0..removed_rs.rules.len() {
214                if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
215                    rule_set: idx,
216                    rule: rule_idx,
217                }) {
218                    changed.push(r_id);
219                }
220                if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
221                    rule_set: idx,
222                    rule: rule_idx,
223                }) {
224                    changed.push(resp_id);
225                }
226            }
227        }
228
229        // Actually remove.
230        self.config.service.rule_sets.remove(idx);
231        if let Some(paths) = self.config.service.rule_sets_file_paths.as_mut() {
232            if idx < paths.len() {
233                paths.remove(idx);
234            }
235        }
236
237        // Migrate IDs: everything at `idx` onwards in the *old* layout
238        // needs its address remapped. The clean approach: gather the
239        // old (address → id) pairs we care about, clear the entries
240        // affected by the shift, re-insert with new addresses.
241        self.shift_rule_sets_down(idx);
242
243        // Every shifted rule set's ID remains valid but its address
244        // has changed; surface those IDs too so the GUI refreshes
245        // their position indicators.
246        for shifted_idx in idx..self.config.service.rule_sets.len() {
247            if let Some(shifted_id) = self
248                .ids
249                .id_for(NodeAddress::RuleSet {
250                    rule_set: shifted_idx,
251                })
252            {
253                if !changed.contains(&shifted_id) {
254                    changed.push(shifted_id);
255                }
256            }
257        }
258
259        Ok(changed)
260    }
261
262    fn cmd_add_rule(
263        &mut self,
264        parent: NodeId,
265        rule_payload: crate::view::RulePayload,
266    ) -> Result<Vec<NodeId>, ApplyError> {
267        let addr = self
268            .ids
269            .lookup(parent)
270            .ok_or(ApplyError::UnknownNode { id: parent })?;
271        let NodeAddress::RuleSet { rule_set: rs_idx } = addr else {
272            return Err(ApplyError::WrongNodeKind {
273                id: parent,
274                reason: "expected a rule set id (parent for AddRule must be a rule set)".to_owned(),
275            });
276        };
277
278        let rule_set = self
279            .config
280            .service
281            .rule_sets
282            .get_mut(rs_idx)
283            .ok_or_else(|| ApplyError::InvalidPayload {
284                reason: format!("rule set index {} out of range", rs_idx),
285            })?;
286
287        let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx, None)?;
288        let new_rule_idx = rule_set.rules.len();
289        rule_set.rules.push(new_rule);
290
291        let r_id = self.ids.insert(NodeAddress::Rule {
292            rule_set: rs_idx,
293            rule: new_rule_idx,
294        });
295        let resp_id = self.ids.insert(NodeAddress::Respond {
296            rule_set: rs_idx,
297            rule: new_rule_idx,
298        });
299        Ok(vec![parent, r_id, resp_id])
300    }
301
302    fn cmd_update_rule(
303        &mut self,
304        id: NodeId,
305        rule_payload: crate::view::RulePayload,
306    ) -> Result<Vec<NodeId>, ApplyError> {
307        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
308        let NodeAddress::Rule {
309            rule_set: rs_idx,
310            rule: rule_idx,
311        } = addr
312        else {
313            return Err(ApplyError::WrongNodeKind {
314                id,
315                reason: "expected a rule id".to_owned(),
316            });
317        };
318
319        let rule_set = self
320            .config
321            .service
322            .rule_sets
323            .get_mut(rs_idx)
324            .ok_or_else(|| ApplyError::InvalidPayload {
325                reason: format!("rule set index {} out of range", rs_idx),
326            })?;
327
328        // Preserve headers / body match conditions that the GUI's
329        // `RulePayload` doesn't expose — without this, every
330        // `UpdateRule` would silently strip those clauses from the
331        // existing rule. See `build_rule_from_payload`'s rustdoc.
332        let existing = rule_set.rules.get(rule_idx).cloned();
333        let new_rule = build_rule_from_payload(
334            rule_payload,
335            rule_set,
336            rs_idx,
337            existing.as_ref(),
338        )?;
339        *rule_set
340            .rules
341            .get_mut(rule_idx)
342            .ok_or_else(|| ApplyError::InvalidPayload {
343                reason: format!("rule index {} out of range", rule_idx),
344            })? = new_rule;
345
346        let resp_id = self
347            .ids
348            .id_for(NodeAddress::Respond {
349                rule_set: rs_idx,
350                rule: rule_idx,
351            })
352            .unwrap_or_else(NodeId::new);
353        Ok(vec![id, resp_id])
354    }
355
356    fn cmd_delete_rule(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
357        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
358        let NodeAddress::Rule {
359            rule_set: rs_idx,
360            rule: rule_idx,
361        } = addr
362        else {
363            return Err(ApplyError::WrongNodeKind {
364                id,
365                reason: "expected a rule id".to_owned(),
366            });
367        };
368
369        let rule_set = self
370            .config
371            .service
372            .rule_sets
373            .get_mut(rs_idx)
374            .ok_or_else(|| ApplyError::InvalidPayload {
375                reason: format!("rule set index {} out of range", rs_idx),
376            })?;
377
378        if rule_idx >= rule_set.rules.len() {
379            return Err(ApplyError::InvalidPayload {
380                reason: format!("rule index {} out of range", rule_idx),
381            });
382        }
383
384        // Gather IDs that will change.
385        let mut changed: Vec<NodeId> = Vec::new();
386        changed.push(id);
387        if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
388            rule_set: rs_idx,
389            rule: rule_idx,
390        }) {
391            changed.push(resp_id);
392        }
393
394        rule_set.rules.remove(rule_idx);
395        self.shift_rules_down(rs_idx, rule_idx);
396
397        // Shifted rules' ids change their address but not their identity.
398        let new_rule_count = self.config.service.rule_sets[rs_idx].rules.len();
399        for shifted_idx in rule_idx..new_rule_count {
400            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
401                rule_set: rs_idx,
402                rule: shifted_idx,
403            }) {
404                if !changed.contains(&r_id) {
405                    changed.push(r_id);
406                }
407            }
408            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
409                rule_set: rs_idx,
410                rule: shifted_idx,
411            }) {
412                if !changed.contains(&resp_id) {
413                    changed.push(resp_id);
414                }
415            }
416        }
417
418        Ok(changed)
419    }
420
421    fn cmd_move_rule(&mut self, id: NodeId, new_index: usize) -> Result<Vec<NodeId>, ApplyError> {
422        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
423        let NodeAddress::Rule {
424            rule_set: rs_idx,
425            rule: old_idx,
426        } = addr
427        else {
428            return Err(ApplyError::WrongNodeKind {
429                id,
430                reason: "expected a rule id".to_owned(),
431            });
432        };
433
434        let rule_set = self
435            .config
436            .service
437            .rule_sets
438            .get_mut(rs_idx)
439            .ok_or_else(|| ApplyError::InvalidPayload {
440                reason: format!("rule set index {} out of range", rs_idx),
441            })?;
442
443        if old_idx >= rule_set.rules.len() || new_index >= rule_set.rules.len() {
444            return Err(ApplyError::InvalidPayload {
445                reason: format!(
446                    "move out of bounds: old_idx={}, new_index={}, len={}",
447                    old_idx,
448                    new_index,
449                    rule_set.rules.len()
450                ),
451            });
452        }
453        if old_idx == new_index {
454            return Ok(vec![id]);
455        }
456
457        // Do the move in `config`.
458        let rule = rule_set.rules.remove(old_idx);
459        rule_set.rules.insert(new_index, rule);
460
461        // Reshuffle IDs for all rules in this rule set: the simplest
462        // correct approach is to pull out all rule+respond IDs for
463        // this rule-set, reorder them to match the new slice order,
464        // and re-insert.
465        self.reorder_rule_ids(rs_idx, old_idx, new_index);
466
467        // Every rule in [min(old, new) .. max(old, new)] changed address;
468        // report their IDs so the GUI repaints.
469        let lo = old_idx.min(new_index);
470        let hi = old_idx.max(new_index);
471        let mut changed: Vec<NodeId> = Vec::new();
472        for idx in lo..=hi {
473            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
474                rule_set: rs_idx,
475                rule: idx,
476            }) {
477                changed.push(r_id);
478            }
479            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
480                rule_set: rs_idx,
481                rule: idx,
482            }) {
483                changed.push(resp_id);
484            }
485        }
486        Ok(changed)
487    }
488
489    fn cmd_update_respond(
490        &mut self,
491        id: NodeId,
492        respond: crate::view::RespondPayload,
493    ) -> Result<Vec<NodeId>, ApplyError> {
494        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
495        let NodeAddress::Respond {
496            rule_set: rs_idx,
497            rule: rule_idx,
498        } = addr
499        else {
500            return Err(ApplyError::WrongNodeKind {
501                id,
502                reason: "expected a respond id".to_owned(),
503            });
504        };
505
506        let rule = self
507            .config
508            .service
509            .rule_sets
510            .get_mut(rs_idx)
511            .and_then(|rs| rs.rules.get_mut(rule_idx))
512            .ok_or_else(|| ApplyError::InvalidPayload {
513                reason: format!(
514                    "rule at rule_set={}, rule={} not found",
515                    rs_idx, rule_idx
516                ),
517            })?;
518
519        rule.respond = build_respond_from_payload(respond);
520
521        // Re-run status-code derivation so the updated `status` field
522        // has its matching `StatusCode` stored.
523        let rule_set = &self.config.service.rule_sets[rs_idx];
524        let derived = rule_set.rules[rule_idx].compute_derived_fields(rule_set, rule_idx, rs_idx);
525        self.config.service.rule_sets[rs_idx].rules[rule_idx] = derived;
526
527        Ok(vec![id])
528    }
529
530    fn cmd_update_root_setting(
531        &mut self,
532        key: crate::view::RootSettingKey,
533        value: EditValue,
534    ) -> Result<Vec<NodeId>, ApplyError> {
535        use crate::view::RootSettingKey::*;
536
537        match key {
538            ListenerIpAddress => {
539                let s = value_as_string(&value)?;
540                let listener = self.config.listener.get_or_insert_with(Default::default);
541                listener.ip_address = s;
542            }
543            ListenerPort => {
544                let n = value_as_integer(&value)?;
545                if !(0..=u16::MAX as i64).contains(&n) {
546                    return Err(ApplyError::InvalidPayload {
547                        reason: format!("port {} not in 0..=65535", n),
548                    });
549                }
550                let listener = self.config.listener.get_or_insert_with(Default::default);
551                listener.port = n as u16;
552            }
553            ServiceFallbackRespondDir => {
554                let s = value_as_string(&value)?;
555                self.config.service.fallback_respond_dir = s;
556            }
557            ServiceStrategy => {
558                let s = value_as_string(&value)?;
559                use apimock_routing::Strategy;
560                let strategy = match s.as_str() {
561                    "first_match" => Strategy::FirstMatch,
562                    "uniform_random" => Strategy::UniformRandom { seed: None },
563                    "weighted_random" => Strategy::WeightedRandom { seed: None },
564                    "priority" => Strategy::Priority {
565                        tiebreaker: apimock_routing::strategy::PriorityTiebreaker::FirstMatch,
566                    },
567                    "round_robin" => Strategy::RoundRobin,
568                    other => {
569                        return Err(ApplyError::InvalidPayload {
570                            reason: format!("unknown strategy: `{}`", other),
571                        });
572                    }
573                };
574                self.config.service.strategy = Some(strategy);
575            }
576
577            // ── TLS (RFC 003) ──────────────────────────────────────────
578            TlsEnabled => {
579                let enabled = value_as_bool(&value)?;
580                if !enabled {
581                    // Disabling TLS: clear the tls config block.
582                    if let Some(listener) = self.config.listener.as_mut() {
583                        listener.tls = None;
584                    }
585                }
586                // Enabling: the GUI must subsequently set TlsCertFile and
587                // TlsKeyFile before the server can start. We don't create
588                // a skeleton TlsConfig here because that would require
589                // placeholder file paths that would fail validation.
590            }
591            TlsCertFile => {
592                let s = value_as_string(&value)?;
593                let listener = self.config.listener.get_or_insert_with(Default::default);
594                let tls = listener.tls.get_or_insert_with(|| {
595                    crate::config::listener_config::tls_config::TlsConfig {
596                        cert: String::new(),
597                        key: String::new(),
598                        port: None,
599                    }
600                });
601                tls.cert = s;
602            }
603            TlsKeyFile => {
604                let s = value_as_string(&value)?;
605                let listener = self.config.listener.get_or_insert_with(Default::default);
606                let tls = listener.tls.get_or_insert_with(|| {
607                    crate::config::listener_config::tls_config::TlsConfig {
608                        cert: String::new(),
609                        key: String::new(),
610                        port: None,
611                    }
612                });
613                tls.key = s;
614            }
615
616            // ── Log (RFC 003) ──────────────────────────────────────────
617            LogLevel => {
618                let s = value_as_string(&value)?;
619                let valid_levels = ["trace", "debug", "info", "warn", "error"];
620                if !valid_levels.contains(&s.as_str()) {
621                    return Err(ApplyError::InvalidPayload {
622                        reason: format!(
623                            "invalid log level `{}` — valid: trace, debug, info, warn, error",
624                            s
625                        ),
626                    });
627                }
628                // Log level is currently stored in the verbose config as a
629                // boolean; a future RFC may add a string level field.
630                // For now we record the intent in a no-op that can be fleshed
631                // out when the LogConfig gains a `level` string field.
632                let _ = s; // acknowledged but not yet persisted
633            }
634            LogFile => {
635                let s = value_as_string(&value)?;
636                let _ = s; // future: set on a LogConfig.file field
637            }
638            LogFormat => {
639                let s = value_as_string(&value)?;
640                let valid_formats = ["text", "json"];
641                if !valid_formats.contains(&s.as_str()) {
642                    return Err(ApplyError::InvalidPayload {
643                        reason: format!(
644                            "invalid log format `{}` — valid: text, json",
645                            s
646                        ),
647                    });
648                }
649                let _ = s; // future: set on LogConfig.format field
650            }
651
652            // ── file tree view (RFC 012) ───────────────────────────────
653            FileTreeShowHidden => {
654                let b = value_as_bool(&value)?;
655                self.config
656                    .file_tree_view
657                    .get_or_insert_with(Default::default)
658                    .show_hidden = b;
659            }
660            FileTreeBuiltinExcludes => {
661                let b = value_as_bool(&value)?;
662                self.config
663                    .file_tree_view
664                    .get_or_insert_with(Default::default)
665                    .builtin_excludes = b;
666            }
667            FileTreeExtraExcludes => {
668                let list = value_as_string_list(&value)?;
669                self.config
670                    .file_tree_view
671                    .get_or_insert_with(Default::default)
672                    .extra_excludes = list;
673            }
674            FileTreeInclude => {
675                let list = value_as_string_list(&value)?;
676                self.config
677                    .file_tree_view
678                    .get_or_insert_with(Default::default)
679                    .include = list;
680            }
681        }
682
683        let id = self
684            .ids
685            .id_for(NodeAddress::Root)
686            .expect("root id seeded at load");
687        Ok(vec![id])
688    }
689
690    // ── RFC 016: per-condition commands ───────────────────────────────
691
692    fn cmd_add_header_condition(
693        &mut self,
694        rule_id: crate::view::NodeId,
695        payload: crate::view::HeaderConditionPayload,
696    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
697        use apimock_routing::rule_set::rule::when::condition_statement::ConditionStatement;
698
699        let (rs_idx, rule_idx) = self.find_rule_indices(rule_id)?;
700        let op = payload::header_op_to_routing_pub(payload.op);
701        let value = payload.value.unwrap_or_default();
702        let stmt = ConditionStatement { op: Some(op), value };
703        let name = payload.name.to_lowercase();
704
705        // Ensure headers map exists.
706        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
707        let headers = rule.when.request.headers.get_or_insert_with(|| {
708            apimock_routing::rule_set::rule::when::request::headers::Headers(
709                indexmap::IndexMap::new(),
710            )
711        });
712        headers.0.insert(name.clone(), stmt);
713
714        let cond_id = self.ids.insert(NodeAddress::HeaderCondition {
715            rule_set: rs_idx, rule: rule_idx, header_name: name,
716        });
717        let rule_id_out = self
718            .ids
719            .id_for(NodeAddress::Rule { rule_set: rs_idx, rule: rule_idx })
720            .unwrap_or(rule_id);
721        Ok(vec![rule_id_out, cond_id])
722    }
723
724    fn cmd_update_header_condition(
725        &mut self,
726        id: crate::view::NodeId,
727        payload: crate::view::HeaderConditionPayload,
728    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
729        use apimock_routing::rule_set::rule::when::condition_statement::ConditionStatement;
730
731        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
732        let (rs_idx, rule_idx, old_name) = match addr {
733            NodeAddress::HeaderCondition { rule_set, rule, header_name } => {
734                (rule_set, rule, header_name)
735            }
736            _ => return Err(ApplyError::InvalidPayload {
737                reason: "id does not refer to a header condition".to_owned(),
738            }),
739        };
740
741        let op = payload::header_op_to_routing_pub(payload.op);
742        let value = payload.value.unwrap_or_default();
743        let new_name = payload.name.to_lowercase();
744        let stmt = ConditionStatement { op: Some(op), value };
745
746        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
747        let headers = rule.when.request.headers.get_or_insert_with(|| {
748            apimock_routing::rule_set::rule::when::request::headers::Headers(
749                indexmap::IndexMap::new(),
750            )
751        });
752
753        // Remove old key, insert under new name (supports rename).
754        headers.0.shift_remove(&old_name);
755        headers.0.insert(new_name.clone(), stmt);
756
757        // Re-register the condition under the new name.
758        let new_id = self.ids.insert(NodeAddress::HeaderCondition {
759            rule_set: rs_idx, rule: rule_idx, header_name: new_name,
760        });
761        Ok(vec![new_id])
762    }
763
764    fn cmd_remove_header_condition(
765        &mut self,
766        id: crate::view::NodeId,
767    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
768        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
769        let (rs_idx, rule_idx, name) = match addr {
770            NodeAddress::HeaderCondition { rule_set, rule, header_name } => {
771                (rule_set, rule, header_name)
772            }
773            _ => return Err(ApplyError::InvalidPayload {
774                reason: "id does not refer to a header condition".to_owned(),
775            }),
776        };
777
778        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
779        if let Some(headers) = rule.when.request.headers.as_mut() {
780            headers.0.shift_remove(&name);
781            if headers.0.is_empty() {
782                rule.when.request.headers = None;
783            }
784        }
785
786        let rule_id = self
787            .ids
788            .id_for(NodeAddress::Rule { rule_set: rs_idx, rule: rule_idx })
789            .unwrap_or(id);
790        Ok(vec![rule_id])
791    }
792
793    fn cmd_add_body_condition(
794        &mut self,
795        rule_id: crate::view::NodeId,
796        payload: crate::view::BodyConditionPayload,
797    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
798        use apimock_routing::rule_set::rule::when::request::body::{
799            Body, BodyConditionStatement, body_kind::BodyKind,
800        };
801
802        let (rs_idx, rule_idx) = self.find_rule_indices(rule_id)?;
803        let op = payload::body_op_to_routing_pub(payload.op);
804        let value = payload::json_value_to_string_pub(&payload.value);
805        let stmt = BodyConditionStatement { op: Some(op), value };
806        let path = payload.path.clone();
807
808        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
809        if rule.when.request.body.is_none() {
810            rule.when.request.body = Some(Body(std::collections::HashMap::new()));
811        }
812        let body_map = rule.when.request.body.as_mut().unwrap();
813        body_map
814            .0
815            .entry(BodyKind::Json)
816            .or_insert_with(indexmap::IndexMap::new)
817            .insert(path.clone(), stmt);
818
819        let cond_id = self.ids.insert(NodeAddress::BodyCondition {
820            rule_set: rs_idx, rule: rule_idx, path,
821        });
822        let rule_id_out = self
823            .ids
824            .id_for(NodeAddress::Rule { rule_set: rs_idx, rule: rule_idx })
825            .unwrap_or(rule_id);
826        Ok(vec![rule_id_out, cond_id])
827    }
828
829    fn cmd_update_body_condition(
830        &mut self,
831        id: crate::view::NodeId,
832        payload: crate::view::BodyConditionPayload,
833    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
834        use apimock_routing::rule_set::rule::when::request::body::BodyConditionStatement;
835
836        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
837        let (rs_idx, rule_idx, old_path) = match addr {
838            NodeAddress::BodyCondition { rule_set, rule, path } => (rule_set, rule, path),
839            _ => return Err(ApplyError::InvalidPayload {
840                reason: "id does not refer to a body condition".to_owned(),
841            }),
842        };
843
844        let op    = payload::body_op_to_routing_pub(payload.op);
845        let value = payload::json_value_to_string_pub(&payload.value);
846        let new_path = payload.path.clone();
847        let stmt = BodyConditionStatement { op: Some(op), value };
848
849        use apimock_routing::rule_set::rule::when::request::body::body_kind::BodyKind;
850        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
851        if let Some(body) = rule.when.request.body.as_mut() {
852            if let Some(json_map) = body.0.get_mut(&BodyKind::Json) {
853                json_map.shift_remove(&old_path);
854                json_map.insert(new_path.clone(), stmt);
855            }
856        }
857
858        let new_id = self.ids.insert(NodeAddress::BodyCondition {
859            rule_set: rs_idx, rule: rule_idx, path: new_path,
860        });
861        Ok(vec![new_id])
862    }
863
864    fn cmd_remove_body_condition(
865        &mut self,
866        id: crate::view::NodeId,
867    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
868        use apimock_routing::rule_set::rule::when::request::body::body_kind::BodyKind;
869
870        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
871        let (rs_idx, rule_idx, path) = match addr {
872            NodeAddress::BodyCondition { rule_set, rule, path } => (rule_set, rule, path),
873            _ => return Err(ApplyError::InvalidPayload {
874                reason: "id does not refer to a body condition".to_owned(),
875            }),
876        };
877
878        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
879        if let Some(body) = rule.when.request.body.as_mut() {
880            if let Some(json_map) = body.0.get_mut(&BodyKind::Json) {
881                json_map.shift_remove(&path);
882            }
883        }
884
885        let rule_id = self
886            .ids
887            .id_for(NodeAddress::Rule { rule_set: rs_idx, rule: rule_idx })
888            .unwrap_or(id);
889        Ok(vec![rule_id])
890    }
891
892    /// Resolve a rule's `(rule_set_idx, rule_idx)` pair from its `NodeId`.
893    fn find_rule_indices(
894        &self,
895        rule_id: crate::view::NodeId,
896    ) -> Result<(usize, usize), ApplyError> {
897        match self.ids.lookup(rule_id) {
898            Some(NodeAddress::Rule { rule_set, rule }) => Ok((rule_set, rule)),
899            _ => Err(ApplyError::UnknownNode { id: rule_id }),
900        }
901    }
902}