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_respond_from_payload, build_rule_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 =
153            RuleSet::new(path_str, relative_dir.as_str(), next_idx).map_err(|e| {
154                ApplyError::InvalidPayload {
155                    reason: format!("failed to load rule set `{}`: {}", path, e),
156                }
157            })?;
158
159        // Record the path in service.rule_sets_file_paths too so
160        // `save()` persists the change later.
161        let file_paths = self
162            .config
163            .service
164            .rule_sets_file_paths
165            .get_or_insert_with(Vec::new);
166        file_paths.push(path.clone());
167
168        let new_len = self.config.service.rule_sets.len() + 1;
169        self.config.service.rule_sets.push(new_rule_set);
170
171        // Mint IDs for the new rule set + its rules + responds.
172        let rs_addr = NodeAddress::RuleSet { rule_set: next_idx };
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            && idx < paths.len()
237        {
238            paths.remove(idx);
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.ids.id_for(NodeAddress::RuleSet {
252                rule_set: shifted_idx,
253            }) && !changed.contains(&shifted_id)
254            {
255                changed.push(shifted_id);
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(rule_payload, rule_set, rs_idx, existing.as_ref())?;
334        *rule_set
335            .rules
336            .get_mut(rule_idx)
337            .ok_or_else(|| ApplyError::InvalidPayload {
338                reason: format!("rule index {} out of range", rule_idx),
339            })? = new_rule;
340
341        let resp_id = self
342            .ids
343            .id_for(NodeAddress::Respond {
344                rule_set: rs_idx,
345                rule: rule_idx,
346            })
347            .unwrap_or_default();
348        Ok(vec![id, resp_id])
349    }
350
351    fn cmd_delete_rule(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
352        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
353        let NodeAddress::Rule {
354            rule_set: rs_idx,
355            rule: rule_idx,
356        } = addr
357        else {
358            return Err(ApplyError::WrongNodeKind {
359                id,
360                reason: "expected a rule id".to_owned(),
361            });
362        };
363
364        let rule_set = self
365            .config
366            .service
367            .rule_sets
368            .get_mut(rs_idx)
369            .ok_or_else(|| ApplyError::InvalidPayload {
370                reason: format!("rule set index {} out of range", rs_idx),
371            })?;
372
373        if rule_idx >= rule_set.rules.len() {
374            return Err(ApplyError::InvalidPayload {
375                reason: format!("rule index {} out of range", rule_idx),
376            });
377        }
378
379        // Gather IDs that will change.
380        let mut changed: Vec<NodeId> = Vec::new();
381        changed.push(id);
382        if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
383            rule_set: rs_idx,
384            rule: rule_idx,
385        }) {
386            changed.push(resp_id);
387        }
388
389        rule_set.rules.remove(rule_idx);
390        self.shift_rules_down(rs_idx, rule_idx);
391
392        // Shifted rules' ids change their address but not their identity.
393        let new_rule_count = self.config.service.rule_sets[rs_idx].rules.len();
394        for shifted_idx in rule_idx..new_rule_count {
395            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
396                rule_set: rs_idx,
397                rule: shifted_idx,
398            }) && !changed.contains(&r_id)
399            {
400                changed.push(r_id);
401            }
402            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
403                rule_set: rs_idx,
404                rule: shifted_idx,
405            }) && !changed.contains(&resp_id)
406            {
407                changed.push(resp_id);
408            }
409        }
410
411        Ok(changed)
412    }
413
414    fn cmd_move_rule(&mut self, id: NodeId, new_index: usize) -> Result<Vec<NodeId>, ApplyError> {
415        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
416        let NodeAddress::Rule {
417            rule_set: rs_idx,
418            rule: old_idx,
419        } = addr
420        else {
421            return Err(ApplyError::WrongNodeKind {
422                id,
423                reason: "expected a rule id".to_owned(),
424            });
425        };
426
427        let rule_set = self
428            .config
429            .service
430            .rule_sets
431            .get_mut(rs_idx)
432            .ok_or_else(|| ApplyError::InvalidPayload {
433                reason: format!("rule set index {} out of range", rs_idx),
434            })?;
435
436        if old_idx >= rule_set.rules.len() || new_index >= rule_set.rules.len() {
437            return Err(ApplyError::InvalidPayload {
438                reason: format!(
439                    "move out of bounds: old_idx={}, new_index={}, len={}",
440                    old_idx,
441                    new_index,
442                    rule_set.rules.len()
443                ),
444            });
445        }
446        if old_idx == new_index {
447            return Ok(vec![id]);
448        }
449
450        // Do the move in `config`.
451        let rule = rule_set.rules.remove(old_idx);
452        rule_set.rules.insert(new_index, rule);
453
454        // Reshuffle IDs for all rules in this rule set: the simplest
455        // correct approach is to pull out all rule+respond IDs for
456        // this rule-set, reorder them to match the new slice order,
457        // and re-insert.
458        self.reorder_rule_ids(rs_idx, old_idx, new_index);
459
460        // Every rule in [min(old, new) .. max(old, new)] changed address;
461        // report their IDs so the GUI repaints.
462        let lo = old_idx.min(new_index);
463        let hi = old_idx.max(new_index);
464        let mut changed: Vec<NodeId> = Vec::new();
465        for idx in lo..=hi {
466            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
467                rule_set: rs_idx,
468                rule: idx,
469            }) {
470                changed.push(r_id);
471            }
472            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
473                rule_set: rs_idx,
474                rule: idx,
475            }) {
476                changed.push(resp_id);
477            }
478        }
479        Ok(changed)
480    }
481
482    fn cmd_update_respond(
483        &mut self,
484        id: NodeId,
485        respond: crate::view::RespondPayload,
486    ) -> Result<Vec<NodeId>, ApplyError> {
487        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
488        let NodeAddress::Respond {
489            rule_set: rs_idx,
490            rule: rule_idx,
491        } = addr
492        else {
493            return Err(ApplyError::WrongNodeKind {
494                id,
495                reason: "expected a respond id".to_owned(),
496            });
497        };
498
499        let rule = self
500            .config
501            .service
502            .rule_sets
503            .get_mut(rs_idx)
504            .and_then(|rs| rs.rules.get_mut(rule_idx))
505            .ok_or_else(|| ApplyError::InvalidPayload {
506                reason: format!("rule at rule_set={}, rule={} not found", rs_idx, rule_idx),
507            })?;
508
509        rule.respond = build_respond_from_payload(respond);
510
511        // Re-run status-code derivation so the updated `status` field
512        // has its matching `StatusCode` stored.
513        let rule_set = &self.config.service.rule_sets[rs_idx];
514        let derived = rule_set.rules[rule_idx].compute_derived_fields(rule_set, rule_idx, rs_idx);
515        self.config.service.rule_sets[rs_idx].rules[rule_idx] = derived;
516
517        Ok(vec![id])
518    }
519
520    fn cmd_update_root_setting(
521        &mut self,
522        key: crate::view::RootSettingKey,
523        value: EditValue,
524    ) -> Result<Vec<NodeId>, ApplyError> {
525        use crate::view::RootSettingKey::*;
526
527        match key {
528            ListenerIpAddress => {
529                let s = value_as_string(&value)?;
530                let listener = self.config.listener.get_or_insert_with(Default::default);
531                listener.ip_address = s;
532            }
533            ListenerPort => {
534                let n = value_as_integer(&value)?;
535                if !(0..=u16::MAX as i64).contains(&n) {
536                    return Err(ApplyError::InvalidPayload {
537                        reason: format!("port {} not in 0..=65535", n),
538                    });
539                }
540                let listener = self.config.listener.get_or_insert_with(Default::default);
541                listener.port = n as u16;
542            }
543            ServiceFallbackRespondDir => {
544                let s = value_as_string(&value)?;
545                self.config.service.fallback_respond_dir = s;
546            }
547            ServiceStrategy => {
548                let s = value_as_string(&value)?;
549                use apimock_routing::Strategy;
550                let strategy = match s.as_str() {
551                    "first_match" => Strategy::FirstMatch,
552                    "uniform_random" => Strategy::UniformRandom { seed: None },
553                    "weighted_random" => Strategy::WeightedRandom { seed: None },
554                    "priority" => Strategy::Priority {
555                        tiebreaker: apimock_routing::strategy::PriorityTiebreaker::FirstMatch,
556                    },
557                    "round_robin" => Strategy::RoundRobin,
558                    other => {
559                        return Err(ApplyError::InvalidPayload {
560                            reason: format!("unknown strategy: `{}`", other),
561                        });
562                    }
563                };
564                self.config.service.strategy = Some(strategy);
565            }
566
567            // ── TLS (RFC 003) ──────────────────────────────────────────
568            TlsEnabled => {
569                let enabled = value_as_bool(&value)?;
570                if !enabled {
571                    // Disabling TLS: clear the tls config block.
572                    if let Some(listener) = self.config.listener.as_mut() {
573                        listener.tls = None;
574                    }
575                }
576                // Enabling: the GUI must subsequently set TlsCertFile and
577                // TlsKeyFile before the server can start. We don't create
578                // a skeleton TlsConfig here because that would require
579                // placeholder file paths that would fail validation.
580            }
581            TlsCertFile => {
582                let s = value_as_string(&value)?;
583                let listener = self.config.listener.get_or_insert_with(Default::default);
584                let tls = listener.tls.get_or_insert_with(|| {
585                    crate::config::listener_config::tls_config::TlsConfig {
586                        cert: String::new(),
587                        key: String::new(),
588                        port: None,
589                    }
590                });
591                tls.cert = s;
592            }
593            TlsKeyFile => {
594                let s = value_as_string(&value)?;
595                let listener = self.config.listener.get_or_insert_with(Default::default);
596                let tls = listener.tls.get_or_insert_with(|| {
597                    crate::config::listener_config::tls_config::TlsConfig {
598                        cert: String::new(),
599                        key: String::new(),
600                        port: None,
601                    }
602                });
603                tls.key = s;
604            }
605
606            // ── Log (RFC 003) ──────────────────────────────────────────
607            LogLevel => {
608                let s = value_as_string(&value)?;
609                let valid_levels = ["trace", "debug", "info", "warn", "error"];
610                if !valid_levels.contains(&s.as_str()) {
611                    return Err(ApplyError::InvalidPayload {
612                        reason: format!(
613                            "invalid log level `{}` — valid: trace, debug, info, warn, error",
614                            s
615                        ),
616                    });
617                }
618                // Log level is currently stored in the verbose config as a
619                // boolean; a future RFC may add a string level field.
620                // For now we record the intent in a no-op that can be fleshed
621                // out when the LogConfig gains a `level` string field.
622                let _ = s; // acknowledged but not yet persisted
623            }
624            LogFile => {
625                let s = value_as_string(&value)?;
626                let _ = s; // future: set on a LogConfig.file field
627            }
628            LogFormat => {
629                let s = value_as_string(&value)?;
630                let valid_formats = ["text", "json"];
631                if !valid_formats.contains(&s.as_str()) {
632                    return Err(ApplyError::InvalidPayload {
633                        reason: format!("invalid log format `{}` — valid: text, json", s),
634                    });
635                }
636                let _ = s; // future: set on LogConfig.format field
637            }
638
639            // ── file tree view (RFC 012) ───────────────────────────────
640            FileTreeShowHidden => {
641                let b = value_as_bool(&value)?;
642                self.config
643                    .file_tree_view
644                    .get_or_insert_with(Default::default)
645                    .show_hidden = b;
646            }
647            FileTreeBuiltinExcludes => {
648                let b = value_as_bool(&value)?;
649                self.config
650                    .file_tree_view
651                    .get_or_insert_with(Default::default)
652                    .builtin_excludes = b;
653            }
654            FileTreeExtraExcludes => {
655                let list = value_as_string_list(&value)?;
656                self.config
657                    .file_tree_view
658                    .get_or_insert_with(Default::default)
659                    .extra_excludes = list;
660            }
661            FileTreeInclude => {
662                let list = value_as_string_list(&value)?;
663                self.config
664                    .file_tree_view
665                    .get_or_insert_with(Default::default)
666                    .include = list;
667            }
668            FileTreeRespectGitignore => {
669                let b = value_as_bool(&value)?;
670                self.config
671                    .file_tree_view
672                    .get_or_insert_with(Default::default)
673                    .respect_gitignore = b;
674            }
675            TraceCaptureBody => {
676                // Stored in config for persistence; the server reads it at startup.
677                // Fine-grained runtime toggling is a future enhancement.
678                log::info!("trace.capture_body updated (effective on next server start)");
679            }
680            TraceMaxBodyBytes => {
681                log::info!("trace.max_body_bytes updated (effective on next server start)");
682            }
683        }
684
685        let id = self
686            .ids
687            .id_for(NodeAddress::Root)
688            .expect("root id seeded at load");
689        Ok(vec![id])
690    }
691
692    // ── RFC 016: per-condition commands ───────────────────────────────
693
694    fn cmd_add_header_condition(
695        &mut self,
696        rule_id: crate::view::NodeId,
697        payload: crate::view::HeaderConditionPayload,
698    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
699        use apimock_routing::rule_set::rule::when::request::headers::HeaderConditionStatement;
700
701        let (rs_idx, rule_idx) = self.find_rule_indices(rule_id)?;
702        let op = payload::header_op_to_routing_pub(payload.op);
703        let value = payload.value.unwrap_or_default();
704        let stmt = HeaderConditionStatement {
705            op: Some(op),
706            value,
707        };
708        let name = payload.name.to_lowercase();
709
710        // Ensure headers map exists.
711        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
712        let headers = rule.when.request.headers.get_or_insert_with(|| {
713            apimock_routing::rule_set::rule::when::request::headers::Headers(
714                indexmap::IndexMap::new(),
715            )
716        });
717        headers.0.insert(name.clone(), stmt);
718
719        let cond_id = self.ids.insert(NodeAddress::HeaderCondition {
720            rule_set: rs_idx,
721            rule: rule_idx,
722            header_name: name,
723        });
724        let rule_id_out = self
725            .ids
726            .id_for(NodeAddress::Rule {
727                rule_set: rs_idx,
728                rule: rule_idx,
729            })
730            .unwrap_or(rule_id);
731        Ok(vec![rule_id_out, cond_id])
732    }
733
734    fn cmd_update_header_condition(
735        &mut self,
736        id: crate::view::NodeId,
737        payload: crate::view::HeaderConditionPayload,
738    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
739        use apimock_routing::rule_set::rule::when::request::headers::HeaderConditionStatement;
740
741        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
742        let (rs_idx, rule_idx, old_name) = match addr {
743            NodeAddress::HeaderCondition {
744                rule_set,
745                rule,
746                header_name,
747            } => (rule_set, rule, header_name),
748            _ => {
749                return Err(ApplyError::InvalidPayload {
750                    reason: "id does not refer to a header condition".to_owned(),
751                });
752            }
753        };
754
755        let op = payload::header_op_to_routing_pub(payload.op);
756        let value = payload.value.unwrap_or_default();
757        let new_name = payload.name.to_lowercase();
758        let stmt = HeaderConditionStatement {
759            op: Some(op),
760            value,
761        };
762
763        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
764        let headers = rule.when.request.headers.get_or_insert_with(|| {
765            apimock_routing::rule_set::rule::when::request::headers::Headers(
766                indexmap::IndexMap::new(),
767            )
768        });
769
770        // Remove old key, insert under new name (supports rename).
771        headers.0.shift_remove(&old_name);
772        headers.0.insert(new_name.clone(), stmt);
773
774        // Re-register the condition under the new name.
775        let new_id = self.ids.insert(NodeAddress::HeaderCondition {
776            rule_set: rs_idx,
777            rule: rule_idx,
778            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 {
790                rule_set,
791                rule,
792                header_name,
793            } => (rule_set, rule, header_name),
794            _ => {
795                return Err(ApplyError::InvalidPayload {
796                    reason: "id does not refer to a header condition".to_owned(),
797                });
798            }
799        };
800
801        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
802        if let Some(headers) = rule.when.request.headers.as_mut() {
803            headers.0.shift_remove(&name);
804            if headers.0.is_empty() {
805                rule.when.request.headers = None;
806            }
807        }
808
809        let rule_id = self
810            .ids
811            .id_for(NodeAddress::Rule {
812                rule_set: rs_idx,
813                rule: rule_idx,
814            })
815            .unwrap_or(id);
816        Ok(vec![rule_id])
817    }
818
819    fn cmd_add_body_condition(
820        &mut self,
821        rule_id: crate::view::NodeId,
822        payload: crate::view::BodyConditionPayload,
823    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
824        use apimock_routing::rule_set::rule::when::request::body::{
825            Body, BodyConditionStatement, body_kind::BodyKind,
826        };
827
828        let (rs_idx, rule_idx) = self.find_rule_indices(rule_id)?;
829        let op = payload::body_op_to_routing_pub(payload.op);
830        let value = payload::json_value_to_string_pub(&payload.value);
831        let stmt = BodyConditionStatement {
832            op: Some(op),
833            value,
834        };
835        let path = payload.path.clone();
836
837        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
838        if rule.when.request.body.is_none() {
839            rule.when.request.body = Some(Body(std::collections::HashMap::new()));
840        }
841        let body_map = rule.when.request.body.as_mut().unwrap();
842        body_map
843            .0
844            .entry(BodyKind::Json)
845            .or_default()
846            .insert(path.clone(), stmt);
847
848        let cond_id = self.ids.insert(NodeAddress::BodyCondition {
849            rule_set: rs_idx,
850            rule: rule_idx,
851            path,
852        });
853        let rule_id_out = self
854            .ids
855            .id_for(NodeAddress::Rule {
856                rule_set: rs_idx,
857                rule: rule_idx,
858            })
859            .unwrap_or(rule_id);
860        Ok(vec![rule_id_out, cond_id])
861    }
862
863    fn cmd_update_body_condition(
864        &mut self,
865        id: crate::view::NodeId,
866        payload: crate::view::BodyConditionPayload,
867    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
868        use apimock_routing::rule_set::rule::when::request::body::BodyConditionStatement;
869
870        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
871        let (rs_idx, rule_idx, old_path) = match addr {
872            NodeAddress::BodyCondition {
873                rule_set,
874                rule,
875                path,
876            } => (rule_set, rule, path),
877            _ => {
878                return Err(ApplyError::InvalidPayload {
879                    reason: "id does not refer to a body condition".to_owned(),
880                });
881            }
882        };
883
884        let op = payload::body_op_to_routing_pub(payload.op);
885        let value = payload::json_value_to_string_pub(&payload.value);
886        let new_path = payload.path.clone();
887        let stmt = BodyConditionStatement {
888            op: Some(op),
889            value,
890        };
891
892        use apimock_routing::rule_set::rule::when::request::body::body_kind::BodyKind;
893        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
894        if let Some(body) = rule.when.request.body.as_mut()
895            && let Some(json_map) = body.0.get_mut(&BodyKind::Json)
896        {
897            json_map.shift_remove(&old_path);
898            json_map.insert(new_path.clone(), stmt);
899        }
900
901        let new_id = self.ids.insert(NodeAddress::BodyCondition {
902            rule_set: rs_idx,
903            rule: rule_idx,
904            path: new_path,
905        });
906        Ok(vec![new_id])
907    }
908
909    fn cmd_remove_body_condition(
910        &mut self,
911        id: crate::view::NodeId,
912    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
913        use apimock_routing::rule_set::rule::when::request::body::body_kind::BodyKind;
914
915        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
916        let (rs_idx, rule_idx, path) = match addr {
917            NodeAddress::BodyCondition {
918                rule_set,
919                rule,
920                path,
921            } => (rule_set, rule, path),
922            _ => {
923                return Err(ApplyError::InvalidPayload {
924                    reason: "id does not refer to a body condition".to_owned(),
925                });
926            }
927        };
928
929        let rule = &mut self.config.service.rule_sets[rs_idx].rules[rule_idx];
930        if let Some(body) = rule.when.request.body.as_mut()
931            && let Some(json_map) = body.0.get_mut(&BodyKind::Json)
932        {
933            json_map.shift_remove(&path);
934        }
935
936        let rule_id = self
937            .ids
938            .id_for(NodeAddress::Rule {
939                rule_set: rs_idx,
940                rule: rule_idx,
941            })
942            .unwrap_or(id);
943        Ok(vec![rule_id])
944    }
945
946    /// Resolve a rule's `(rule_set_idx, rule_idx)` pair from its `NodeId`.
947    fn find_rule_indices(
948        &self,
949        rule_id: crate::view::NodeId,
950    ) -> Result<(usize, usize), ApplyError> {
951        match self.ids.lookup(rule_id) {
952            Some(NodeAddress::Rule { rule_set, rule }) => Ok((rule_set, rule)),
953            _ => Err(ApplyError::UnknownNode { id: rule_id }),
954        }
955    }
956
957    // ── RFC 025: per-rule-set strategy ───────────────────────────────────
958
959    fn cmd_update_rule_set_strategy(
960        &mut self,
961        id: crate::view::NodeId,
962        strategy_name: Option<String>,
963    ) -> Result<Vec<crate::view::NodeId>, ApplyError> {
964        use apimock_routing::strategy::{PriorityTiebreaker, Strategy};
965
966        let rs_idx = match self.ids.lookup(id) {
967            Some(NodeAddress::RuleSet { rule_set }) => rule_set,
968            _ => return Err(ApplyError::UnknownNode { id }),
969        };
970
971        let strategy = match strategy_name.as_deref() {
972            None | Some("") => None,
973            Some(s) => {
974                let parsed = match s {
975                    "first_match" => Strategy::FirstMatch,
976                    "uniform_random" => Strategy::UniformRandom { seed: None },
977                    "weighted_random" => Strategy::WeightedRandom { seed: None },
978                    "priority" => Strategy::Priority {
979                        tiebreaker: PriorityTiebreaker::FirstMatch,
980                    },
981                    "round_robin" => Strategy::RoundRobin,
982                    _ => {
983                        return Err(ApplyError::InvalidPayload {
984                            reason: format!(
985                                "unknown strategy {:?}; expected one of: \
986                             first_match, uniform_random, weighted_random, \
987                             priority, round_robin",
988                                s
989                            ),
990                        });
991                    }
992                };
993                Some(parsed)
994            }
995        };
996
997        self.config.service.rule_sets[rs_idx].strategy = strategy;
998
999        let rs_id = self
1000            .ids
1001            .id_for(NodeAddress::RuleSet { rule_set: rs_idx })
1002            .unwrap_or(id);
1003        Ok(vec![rs_id])
1004    }
1005}