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_integer,
35    value_as_string,
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                // Root settings include listener port / ip, which change
91                // how the listener binds. Those need a full restart, not
92                // just a reload — the caller reads `reload_hint` from
93                // save() for the fine-grained hint; at apply time we
94                // conservatively flag `requires_reload = true`.
95                (ids, true)
96            }
97        };
98
99        // After any mutation, refresh per-node validation so the
100        // `ApplyResult.diagnostics` reflects the new state. This is the
101        // Step-3 piece: validation is now per-node and GUI-ready, not a
102        // bare boolean.
103        let diagnostics = self.collect_diagnostics();
104
105        Ok(ApplyResult {
106            changed_nodes,
107            diagnostics,
108            requires_reload,
109        })
110    }
111
112    // --- Individual command implementations --------------------------
113
114    fn cmd_add_rule_set(&mut self, path: String) -> Result<Vec<NodeId>, ApplyError> {
115        // Resolve the path against the root's parent dir (same
116        // convention as `Config::new`), then load the rule set.
117        let relative_dir = self.config_relative_dir().map_err(internal_path_err)?;
118        let joined = Path::new(&relative_dir).join(&path);
119        let path_str = joined.to_str().ok_or_else(|| ApplyError::InvalidPayload {
120            reason: format!(
121                "path contains non-UTF-8 bytes: {}",
122                joined.to_string_lossy()
123            ),
124        })?;
125
126        let next_idx = self.config.service.rule_sets.len();
127        let new_rule_set = RuleSet::new(path_str, relative_dir.as_str(), next_idx)
128            .map_err(|e| ApplyError::InvalidPayload {
129                reason: format!("failed to load rule set `{}`: {}", path, e),
130            })?;
131
132        // Record the path in service.rule_sets_file_paths too so
133        // `save()` persists the change later.
134        let file_paths = self
135            .config
136            .service
137            .rule_sets_file_paths
138            .get_or_insert_with(Vec::new);
139        file_paths.push(path.clone());
140
141        let new_len = self.config.service.rule_sets.len() + 1;
142        self.config.service.rule_sets.push(new_rule_set);
143
144        // Mint IDs for the new rule set + its rules + responds.
145        let rs_addr = NodeAddress::RuleSet {
146            rule_set: next_idx,
147        };
148        let rs_id = self.ids.insert(rs_addr);
149        let mut changed = vec![rs_id];
150        let new_rs = &self.config.service.rule_sets[next_idx];
151        for rule_idx in 0..new_rs.rules.len() {
152            let r_id = self.ids.insert(NodeAddress::Rule {
153                rule_set: next_idx,
154                rule: rule_idx,
155            });
156            let resp_id = self.ids.insert(NodeAddress::Respond {
157                rule_set: next_idx,
158                rule: rule_idx,
159            });
160            changed.push(r_id);
161            changed.push(resp_id);
162        }
163        // Sanity: new_len is purely informational here, but makes
164        // the invariant explicit to anyone reading the code.
165        debug_assert_eq!(new_len, self.config.service.rule_sets.len());
166
167        Ok(changed)
168    }
169
170    fn cmd_remove_rule_set(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
171        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
172        let NodeAddress::RuleSet { rule_set: idx } = addr else {
173            return Err(ApplyError::WrongNodeKind {
174                id,
175                reason: "expected a rule set id".to_owned(),
176            });
177        };
178
179        let len = self.config.service.rule_sets.len();
180        if idx >= len {
181            return Err(ApplyError::InvalidPayload {
182                reason: format!("rule set index {} out of range (len={})", idx, len),
183            });
184        }
185
186        // Collect IDs that will change: the removed one plus every rule
187        // set (+ rules + responds) whose index shifts down by one.
188        let mut changed: Vec<NodeId> = Vec::new();
189        // the rule-set itself and its internal nodes (removed)
190        changed.push(id);
191        if let Some(removed_rs) = self.config.service.rule_sets.get(idx) {
192            for rule_idx in 0..removed_rs.rules.len() {
193                if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
194                    rule_set: idx,
195                    rule: rule_idx,
196                }) {
197                    changed.push(r_id);
198                }
199                if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
200                    rule_set: idx,
201                    rule: rule_idx,
202                }) {
203                    changed.push(resp_id);
204                }
205            }
206        }
207
208        // Actually remove.
209        self.config.service.rule_sets.remove(idx);
210        if let Some(paths) = self.config.service.rule_sets_file_paths.as_mut() {
211            if idx < paths.len() {
212                paths.remove(idx);
213            }
214        }
215
216        // Migrate IDs: everything at `idx` onwards in the *old* layout
217        // needs its address remapped. The clean approach: gather the
218        // old (address → id) pairs we care about, clear the entries
219        // affected by the shift, re-insert with new addresses.
220        self.shift_rule_sets_down(idx);
221
222        // Every shifted rule set's ID remains valid but its address
223        // has changed; surface those IDs too so the GUI refreshes
224        // their position indicators.
225        for shifted_idx in idx..self.config.service.rule_sets.len() {
226            if let Some(shifted_id) = self
227                .ids
228                .id_for(NodeAddress::RuleSet {
229                    rule_set: shifted_idx,
230                })
231            {
232                if !changed.contains(&shifted_id) {
233                    changed.push(shifted_id);
234                }
235            }
236        }
237
238        Ok(changed)
239    }
240
241    fn cmd_add_rule(
242        &mut self,
243        parent: NodeId,
244        rule_payload: crate::view::RulePayload,
245    ) -> Result<Vec<NodeId>, ApplyError> {
246        let addr = self
247            .ids
248            .lookup(parent)
249            .ok_or(ApplyError::UnknownNode { id: parent })?;
250        let NodeAddress::RuleSet { rule_set: rs_idx } = addr else {
251            return Err(ApplyError::WrongNodeKind {
252                id: parent,
253                reason: "expected a rule set id (parent for AddRule must be a rule set)".to_owned(),
254            });
255        };
256
257        let rule_set = self
258            .config
259            .service
260            .rule_sets
261            .get_mut(rs_idx)
262            .ok_or_else(|| ApplyError::InvalidPayload {
263                reason: format!("rule set index {} out of range", rs_idx),
264            })?;
265
266        let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx, None)?;
267        let new_rule_idx = rule_set.rules.len();
268        rule_set.rules.push(new_rule);
269
270        let r_id = self.ids.insert(NodeAddress::Rule {
271            rule_set: rs_idx,
272            rule: new_rule_idx,
273        });
274        let resp_id = self.ids.insert(NodeAddress::Respond {
275            rule_set: rs_idx,
276            rule: new_rule_idx,
277        });
278        Ok(vec![parent, r_id, resp_id])
279    }
280
281    fn cmd_update_rule(
282        &mut self,
283        id: NodeId,
284        rule_payload: crate::view::RulePayload,
285    ) -> Result<Vec<NodeId>, ApplyError> {
286        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
287        let NodeAddress::Rule {
288            rule_set: rs_idx,
289            rule: rule_idx,
290        } = addr
291        else {
292            return Err(ApplyError::WrongNodeKind {
293                id,
294                reason: "expected a rule id".to_owned(),
295            });
296        };
297
298        let rule_set = self
299            .config
300            .service
301            .rule_sets
302            .get_mut(rs_idx)
303            .ok_or_else(|| ApplyError::InvalidPayload {
304                reason: format!("rule set index {} out of range", rs_idx),
305            })?;
306
307        // Preserve headers / body match conditions that the GUI's
308        // `RulePayload` doesn't expose — without this, every
309        // `UpdateRule` would silently strip those clauses from the
310        // existing rule. See `build_rule_from_payload`'s rustdoc.
311        let existing = rule_set.rules.get(rule_idx).cloned();
312        let new_rule = build_rule_from_payload(
313            rule_payload,
314            rule_set,
315            rs_idx,
316            existing.as_ref(),
317        )?;
318        *rule_set
319            .rules
320            .get_mut(rule_idx)
321            .ok_or_else(|| ApplyError::InvalidPayload {
322                reason: format!("rule index {} out of range", rule_idx),
323            })? = new_rule;
324
325        let resp_id = self
326            .ids
327            .id_for(NodeAddress::Respond {
328                rule_set: rs_idx,
329                rule: rule_idx,
330            })
331            .unwrap_or_else(NodeId::new);
332        Ok(vec![id, resp_id])
333    }
334
335    fn cmd_delete_rule(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
336        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
337        let NodeAddress::Rule {
338            rule_set: rs_idx,
339            rule: rule_idx,
340        } = addr
341        else {
342            return Err(ApplyError::WrongNodeKind {
343                id,
344                reason: "expected a rule id".to_owned(),
345            });
346        };
347
348        let rule_set = self
349            .config
350            .service
351            .rule_sets
352            .get_mut(rs_idx)
353            .ok_or_else(|| ApplyError::InvalidPayload {
354                reason: format!("rule set index {} out of range", rs_idx),
355            })?;
356
357        if rule_idx >= rule_set.rules.len() {
358            return Err(ApplyError::InvalidPayload {
359                reason: format!("rule index {} out of range", rule_idx),
360            });
361        }
362
363        // Gather IDs that will change.
364        let mut changed: Vec<NodeId> = Vec::new();
365        changed.push(id);
366        if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
367            rule_set: rs_idx,
368            rule: rule_idx,
369        }) {
370            changed.push(resp_id);
371        }
372
373        rule_set.rules.remove(rule_idx);
374        self.shift_rules_down(rs_idx, rule_idx);
375
376        // Shifted rules' ids change their address but not their identity.
377        let new_rule_count = self.config.service.rule_sets[rs_idx].rules.len();
378        for shifted_idx in rule_idx..new_rule_count {
379            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
380                rule_set: rs_idx,
381                rule: shifted_idx,
382            }) {
383                if !changed.contains(&r_id) {
384                    changed.push(r_id);
385                }
386            }
387            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
388                rule_set: rs_idx,
389                rule: shifted_idx,
390            }) {
391                if !changed.contains(&resp_id) {
392                    changed.push(resp_id);
393                }
394            }
395        }
396
397        Ok(changed)
398    }
399
400    fn cmd_move_rule(&mut self, id: NodeId, new_index: usize) -> Result<Vec<NodeId>, ApplyError> {
401        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
402        let NodeAddress::Rule {
403            rule_set: rs_idx,
404            rule: old_idx,
405        } = addr
406        else {
407            return Err(ApplyError::WrongNodeKind {
408                id,
409                reason: "expected a rule id".to_owned(),
410            });
411        };
412
413        let rule_set = self
414            .config
415            .service
416            .rule_sets
417            .get_mut(rs_idx)
418            .ok_or_else(|| ApplyError::InvalidPayload {
419                reason: format!("rule set index {} out of range", rs_idx),
420            })?;
421
422        if old_idx >= rule_set.rules.len() || new_index >= rule_set.rules.len() {
423            return Err(ApplyError::InvalidPayload {
424                reason: format!(
425                    "move out of bounds: old_idx={}, new_index={}, len={}",
426                    old_idx,
427                    new_index,
428                    rule_set.rules.len()
429                ),
430            });
431        }
432        if old_idx == new_index {
433            return Ok(vec![id]);
434        }
435
436        // Do the move in `config`.
437        let rule = rule_set.rules.remove(old_idx);
438        rule_set.rules.insert(new_index, rule);
439
440        // Reshuffle IDs for all rules in this rule set: the simplest
441        // correct approach is to pull out all rule+respond IDs for
442        // this rule-set, reorder them to match the new slice order,
443        // and re-insert.
444        self.reorder_rule_ids(rs_idx, old_idx, new_index);
445
446        // Every rule in [min(old, new) .. max(old, new)] changed address;
447        // report their IDs so the GUI repaints.
448        let lo = old_idx.min(new_index);
449        let hi = old_idx.max(new_index);
450        let mut changed: Vec<NodeId> = Vec::new();
451        for idx in lo..=hi {
452            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
453                rule_set: rs_idx,
454                rule: idx,
455            }) {
456                changed.push(r_id);
457            }
458            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
459                rule_set: rs_idx,
460                rule: idx,
461            }) {
462                changed.push(resp_id);
463            }
464        }
465        Ok(changed)
466    }
467
468    fn cmd_update_respond(
469        &mut self,
470        id: NodeId,
471        respond: crate::view::RespondPayload,
472    ) -> Result<Vec<NodeId>, ApplyError> {
473        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
474        let NodeAddress::Respond {
475            rule_set: rs_idx,
476            rule: rule_idx,
477        } = addr
478        else {
479            return Err(ApplyError::WrongNodeKind {
480                id,
481                reason: "expected a respond id".to_owned(),
482            });
483        };
484
485        let rule = self
486            .config
487            .service
488            .rule_sets
489            .get_mut(rs_idx)
490            .and_then(|rs| rs.rules.get_mut(rule_idx))
491            .ok_or_else(|| ApplyError::InvalidPayload {
492                reason: format!(
493                    "rule at rule_set={}, rule={} not found",
494                    rs_idx, rule_idx
495                ),
496            })?;
497
498        rule.respond = build_respond_from_payload(respond);
499
500        // Re-run status-code derivation so the updated `status` field
501        // has its matching `StatusCode` stored.
502        let rule_set = &self.config.service.rule_sets[rs_idx];
503        let derived = rule_set.rules[rule_idx].compute_derived_fields(rule_set, rule_idx, rs_idx);
504        self.config.service.rule_sets[rs_idx].rules[rule_idx] = derived;
505
506        Ok(vec![id])
507    }
508
509    fn cmd_update_root_setting(
510        &mut self,
511        key: crate::view::RootSettingKey,
512        value: EditValue,
513    ) -> Result<Vec<NodeId>, ApplyError> {
514        use crate::view::RootSettingKey::*;
515
516        match key {
517            ListenerIpAddress => {
518                let s = value_as_string(&value)?;
519                let listener = self.config.listener.get_or_insert_with(Default::default);
520                listener.ip_address = s;
521            }
522            ListenerPort => {
523                let n = value_as_integer(&value)?;
524                if !(0..=u16::MAX as i64).contains(&n) {
525                    return Err(ApplyError::InvalidPayload {
526                        reason: format!("port {} not in 0..=65535", n),
527                    });
528                }
529                let listener = self.config.listener.get_or_insert_with(Default::default);
530                listener.port = n as u16;
531            }
532            ServiceFallbackRespondDir => {
533                let s = value_as_string(&value)?;
534                self.config.service.fallback_respond_dir = s;
535            }
536            ServiceStrategy => {
537                let s = value_as_string(&value)?;
538                // The only recognised strategy value today is
539                // "first_match". Anything else is rejected — if future
540                // strategies are added, extend this match.
541                match s.as_str() {
542                    "first_match" => {
543                        self.config.service.strategy =
544                            Some(apimock_routing::Strategy::FirstMatch);
545                    }
546                    other => {
547                        return Err(ApplyError::InvalidPayload {
548                            reason: format!("unknown strategy: {}", other),
549                        });
550                    }
551                }
552            }
553        }
554
555        // Root is a singleton; its NodeId is always the same.
556        let id = self
557            .ids
558            .id_for(NodeAddress::Root)
559            .expect("root id seeded at load");
560        Ok(vec![id])
561    }
562}