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)?;
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        let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx)?;
308        *rule_set
309            .rules
310            .get_mut(rule_idx)
311            .ok_or_else(|| ApplyError::InvalidPayload {
312                reason: format!("rule index {} out of range", rule_idx),
313            })? = new_rule;
314
315        let resp_id = self
316            .ids
317            .id_for(NodeAddress::Respond {
318                rule_set: rs_idx,
319                rule: rule_idx,
320            })
321            .unwrap_or_else(NodeId::new);
322        Ok(vec![id, resp_id])
323    }
324
325    fn cmd_delete_rule(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
326        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
327        let NodeAddress::Rule {
328            rule_set: rs_idx,
329            rule: rule_idx,
330        } = addr
331        else {
332            return Err(ApplyError::WrongNodeKind {
333                id,
334                reason: "expected a rule id".to_owned(),
335            });
336        };
337
338        let rule_set = self
339            .config
340            .service
341            .rule_sets
342            .get_mut(rs_idx)
343            .ok_or_else(|| ApplyError::InvalidPayload {
344                reason: format!("rule set index {} out of range", rs_idx),
345            })?;
346
347        if rule_idx >= rule_set.rules.len() {
348            return Err(ApplyError::InvalidPayload {
349                reason: format!("rule index {} out of range", rule_idx),
350            });
351        }
352
353        // Gather IDs that will change.
354        let mut changed: Vec<NodeId> = Vec::new();
355        changed.push(id);
356        if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
357            rule_set: rs_idx,
358            rule: rule_idx,
359        }) {
360            changed.push(resp_id);
361        }
362
363        rule_set.rules.remove(rule_idx);
364        self.shift_rules_down(rs_idx, rule_idx);
365
366        // Shifted rules' ids change their address but not their identity.
367        let new_rule_count = self.config.service.rule_sets[rs_idx].rules.len();
368        for shifted_idx in rule_idx..new_rule_count {
369            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
370                rule_set: rs_idx,
371                rule: shifted_idx,
372            }) {
373                if !changed.contains(&r_id) {
374                    changed.push(r_id);
375                }
376            }
377            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
378                rule_set: rs_idx,
379                rule: shifted_idx,
380            }) {
381                if !changed.contains(&resp_id) {
382                    changed.push(resp_id);
383                }
384            }
385        }
386
387        Ok(changed)
388    }
389
390    fn cmd_move_rule(&mut self, id: NodeId, new_index: usize) -> Result<Vec<NodeId>, ApplyError> {
391        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
392        let NodeAddress::Rule {
393            rule_set: rs_idx,
394            rule: old_idx,
395        } = addr
396        else {
397            return Err(ApplyError::WrongNodeKind {
398                id,
399                reason: "expected a rule id".to_owned(),
400            });
401        };
402
403        let rule_set = self
404            .config
405            .service
406            .rule_sets
407            .get_mut(rs_idx)
408            .ok_or_else(|| ApplyError::InvalidPayload {
409                reason: format!("rule set index {} out of range", rs_idx),
410            })?;
411
412        if old_idx >= rule_set.rules.len() || new_index >= rule_set.rules.len() {
413            return Err(ApplyError::InvalidPayload {
414                reason: format!(
415                    "move out of bounds: old_idx={}, new_index={}, len={}",
416                    old_idx,
417                    new_index,
418                    rule_set.rules.len()
419                ),
420            });
421        }
422        if old_idx == new_index {
423            return Ok(vec![id]);
424        }
425
426        // Do the move in `config`.
427        let rule = rule_set.rules.remove(old_idx);
428        rule_set.rules.insert(new_index, rule);
429
430        // Reshuffle IDs for all rules in this rule set: the simplest
431        // correct approach is to pull out all rule+respond IDs for
432        // this rule-set, reorder them to match the new slice order,
433        // and re-insert.
434        self.reorder_rule_ids(rs_idx, old_idx, new_index);
435
436        // Every rule in [min(old, new) .. max(old, new)] changed address;
437        // report their IDs so the GUI repaints.
438        let lo = old_idx.min(new_index);
439        let hi = old_idx.max(new_index);
440        let mut changed: Vec<NodeId> = Vec::new();
441        for idx in lo..=hi {
442            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
443                rule_set: rs_idx,
444                rule: idx,
445            }) {
446                changed.push(r_id);
447            }
448            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
449                rule_set: rs_idx,
450                rule: idx,
451            }) {
452                changed.push(resp_id);
453            }
454        }
455        Ok(changed)
456    }
457
458    fn cmd_update_respond(
459        &mut self,
460        id: NodeId,
461        respond: crate::view::RespondPayload,
462    ) -> Result<Vec<NodeId>, ApplyError> {
463        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
464        let NodeAddress::Respond {
465            rule_set: rs_idx,
466            rule: rule_idx,
467        } = addr
468        else {
469            return Err(ApplyError::WrongNodeKind {
470                id,
471                reason: "expected a respond id".to_owned(),
472            });
473        };
474
475        let rule = self
476            .config
477            .service
478            .rule_sets
479            .get_mut(rs_idx)
480            .and_then(|rs| rs.rules.get_mut(rule_idx))
481            .ok_or_else(|| ApplyError::InvalidPayload {
482                reason: format!(
483                    "rule at rule_set={}, rule={} not found",
484                    rs_idx, rule_idx
485                ),
486            })?;
487
488        rule.respond = build_respond_from_payload(respond);
489
490        // Re-run status-code derivation so the updated `status` field
491        // has its matching `StatusCode` stored.
492        let rule_set = &self.config.service.rule_sets[rs_idx];
493        let derived = rule_set.rules[rule_idx].compute_derived_fields(rule_set, rule_idx, rs_idx);
494        self.config.service.rule_sets[rs_idx].rules[rule_idx] = derived;
495
496        Ok(vec![id])
497    }
498
499    fn cmd_update_root_setting(
500        &mut self,
501        key: crate::view::RootSettingKey,
502        value: EditValue,
503    ) -> Result<Vec<NodeId>, ApplyError> {
504        use crate::view::RootSettingKey::*;
505
506        match key {
507            ListenerIpAddress => {
508                let s = value_as_string(&value)?;
509                let listener = self.config.listener.get_or_insert_with(Default::default);
510                listener.ip_address = s;
511            }
512            ListenerPort => {
513                let n = value_as_integer(&value)?;
514                if !(0..=u16::MAX as i64).contains(&n) {
515                    return Err(ApplyError::InvalidPayload {
516                        reason: format!("port {} not in 0..=65535", n),
517                    });
518                }
519                let listener = self.config.listener.get_or_insert_with(Default::default);
520                listener.port = n as u16;
521            }
522            ServiceFallbackRespondDir => {
523                let s = value_as_string(&value)?;
524                self.config.service.fallback_respond_dir = s;
525            }
526            ServiceStrategy => {
527                let s = value_as_string(&value)?;
528                // The only recognised strategy value today is
529                // "first_match". Anything else is rejected — if future
530                // strategies are added, extend this match.
531                match s.as_str() {
532                    "first_match" => {
533                        self.config.service.strategy =
534                            Some(apimock_routing::Strategy::FirstMatch);
535                    }
536                    other => {
537                        return Err(ApplyError::InvalidPayload {
538                            reason: format!("unknown strategy: {}", other),
539                        });
540                    }
541                }
542            }
543        }
544
545        // Root is a singleton; its NodeId is always the same.
546        let id = self
547            .ids
548            .id_for(NodeAddress::Root)
549            .expect("root id seeded at load");
550        Ok(vec![id])
551    }
552}