Skip to main content

apimock_config/
workspace.rs

1//! The editable workspace: loaded TOML + stable node IDs + edit API.
2//!
3//! # Role in the 5.1 design
4//!
5//! A GUI never touches `Config` or `RuleSet` directly. It holds a
6//! `Workspace` value, calls `snapshot()` to get a read-only view for
7//! rendering, and `apply(EditCommand)` to mutate. Later, `save()`
8//! writes changes back to disk.
9//!
10//! # Stage breakdown
11//!
12//! 5.1.0 implements Steps 1–3 of the spec's §12 plan:
13//!
14//! - **Step 1** (this file) — `load` + `snapshot`.
15//! - **Step 2** — `apply` with the full eight-command set.
16//! - **Step 3** — `validate` producing a `ValidationReport`.
17//!
18//! Steps 4 (`save` + diff) and 5 (richer routing snapshot) are planned
19//! for 5.2+.
20//!
21//! # IDs
22//!
23//! Every editable node gets a v4 UUID at load time. IDs are stable
24//! across `apply()` calls within one `Workspace` instance, so GUI
25//! selection survives edits that reorder or rename surrounding nodes.
26//! IDs are *not* stable across fresh `load()` calls — a reload
27//! regenerates the table, which matches the spec §10 "Workspace は
28//! メモリ上に独立インスタンスを持つ" stance.
29
30use std::{
31    collections::HashMap,
32    path::{Path, PathBuf},
33};
34
35use apimock_routing::{RoutingError, RuleSet};
36
37use crate::{
38    Config,
39    error::{ApplyError, ConfigError, SaveError, WorkspaceError},
40    view::{
41        ApplyResult, ConfigFileKind, ConfigFileView, ConfigNodeView, Diagnostic, EditCommand,
42        EditValue, NodeId, NodeKind, NodeValidation, SaveResult, Severity, ValidationIssue,
43        ValidationReport, WorkspaceSnapshot,
44    },
45};
46
47/// Editable view of an apimock workspace.
48///
49/// # Internal layout
50///
51/// The `Workspace` holds the loaded TOML model (as a `Config`) plus
52/// two index maps:
53///
54/// - `id_to_address`: NodeId → where the node lives in `config`.
55/// - `address_to_id`: reverse — used when rebuilding snapshots.
56///
57/// On every `apply()` that could move nodes around (Add / Remove /
58/// Move), these tables are partially rebuilt. Reloading the config
59/// discards them and re-seeds with fresh IDs.
60pub struct Workspace {
61    /// Path this workspace was loaded from.
62    root_path: PathBuf,
63    /// Loaded TOML model. Authoritative source of truth for
64    /// persistence; edits happen through the editable helpers on
65    /// `Workspace` which keep `config` + id tables in sync.
66    config: Config,
67    /// ID index — see struct doc.
68    ids: IdIndex,
69    /// Workspace-scope diagnostics (e.g. load-time warnings). Per-node
70    /// diagnostics live inside each node's `NodeValidation`.
71    diagnostics: Vec<Diagnostic>,
72    /// Snapshot of every TOML file's on-disk contents at the time of
73    /// load (or last successful save). Save uses this to:
74    ///   - decide which files actually changed (we don't rewrite a
75    ///     file whose rendered content is byte-identical to the
76    ///     baseline);
77    ///   - detect external changes between load and save (the same
78    ///     mechanism could surface "file changed underneath you" in a
79    ///     future stage; 5.2.0 doesn't act on that yet).
80    baseline_files: HashMap<PathBuf, String>,
81}
82
83/// Internal index mapping NodeId to an editable node's logical
84/// address.
85///
86/// # Why a separate enum and not a path string
87///
88/// The `apply` layer needs to mutate the underlying config, which is
89/// only safe if the address is a closed, exhaustively-matchable set.
90/// A free-form `"rule_sets[0].rules[2]"` string would force the apply
91/// code to parse at every edit and silently accept nonsense paths.
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
93enum NodeAddress {
94    /// The root config (there is exactly one).
95    Root,
96    /// A whole rule set, identified by its index in `service.rule_sets`.
97    RuleSet { rule_set: usize },
98    /// A single rule inside a rule set.
99    Rule { rule_set: usize, rule: usize },
100    /// The `respond` block of a rule.
101    Respond { rule_set: usize, rule: usize },
102    /// A middleware file reference, by its index in
103    /// `service.middlewares_file_paths`.
104    Middleware { middleware: usize },
105    /// The fallback respond dir. Singleton — there is one per workspace.
106    FallbackRespondDir,
107}
108
109#[derive(Default)]
110struct IdIndex {
111    id_to_address: HashMap<NodeId, NodeAddress>,
112    address_to_id: HashMap<NodeAddress, NodeId>,
113}
114
115impl IdIndex {
116    fn insert(&mut self, address: NodeAddress) -> NodeId {
117        if let Some(&id) = self.address_to_id.get(&address) {
118            return id;
119        }
120        let id = NodeId::new();
121        self.id_to_address.insert(id, address);
122        self.address_to_id.insert(address, id);
123        id
124    }
125
126    /// Lookup a NodeAddress by id. Used by the apply layer in Step 2.
127    #[allow(dead_code)]
128    fn lookup(&self, id: NodeId) -> Option<NodeAddress> {
129        self.id_to_address.get(&id).copied()
130    }
131
132    fn id_for(&self, address: NodeAddress) -> Option<NodeId> {
133        self.address_to_id.get(&address).copied()
134    }
135}
136
137impl Workspace {
138    /// Load a workspace rooted at the given `apimock.toml`-like path.
139    ///
140    /// Accepts either a direct path to the config file or the
141    /// directory containing one; a missing file-path is searched for
142    /// as `apimock.toml` inside `root`. Mirrors the CLI's existing
143    /// resolution rules.
144    pub fn load(root: PathBuf) -> Result<Self, WorkspaceError> {
145        let resolved = resolve_root(&root)?;
146
147        // Re-use `Config::new` so rule-set loading + validation go
148        // through the same path as the running server. This is
149        // important — the spec's "GUI doesn't break running server
150        // behaviour" invariant (§13) is easiest to guarantee if both
151        // paths share the same loader.
152        let config_path_string = resolved.to_string_lossy().into_owned();
153        let config = Config::new(Some(&config_path_string), None).map_err(WorkspaceError::from)?;
154
155        // Snapshot every TOML file's rendered shape so save() can
156        // tell which files actually have unsaved edits.
157        //
158        // # Why "rendered model" rather than "on-disk text"
159        //
160        // A naive baseline would store the literal on-disk text. But
161        // our writer (`toml_writer`) produces canonicalised TOML —
162        // sorted keys, no comments, double-quoted strings, etc. —
163        // which almost never byte-matches a hand-edited file. With
164        // "on-disk" baseline, `has_unsaved_changes` would return
165        // `true` right after a load with no edits, and the first
166        // save would unconditionally rewrite every file.
167        //
168        // Storing the *rendered* baseline solves this: a freshly
169        // loaded workspace has rendered == baseline by construction,
170        // so `has_unsaved_changes` is false. Edits flip it to true,
171        // and only the files that diverge get rewritten on save.
172        // The user's hand-formatting on never-edited files survives
173        // untouched.
174        let mut baseline_files: HashMap<PathBuf, String> = HashMap::new();
175        baseline_files.insert(
176            resolved.clone(),
177            crate::toml_writer::render_apimock_toml(&config),
178        );
179        for rule_set in config.service.rule_sets.iter() {
180            let path = PathBuf::from(rule_set.file_path.as_str());
181            baseline_files.insert(
182                path,
183                crate::toml_writer::render_rule_set_toml(rule_set),
184            );
185        }
186
187        let mut workspace = Self {
188            root_path: resolved,
189            config,
190            ids: IdIndex::default(),
191            diagnostics: Vec::new(),
192            baseline_files,
193        };
194        workspace.seed_ids();
195        Ok(workspace)
196    }
197
198    /// Assign a fresh NodeId to every editable address in `config`.
199    /// Called from `load` and from any `apply()` path that might
200    /// change the address of existing nodes.
201    ///
202    /// # Why we rebuild rather than patch
203    ///
204    /// `NodeAddress` carries positional indices (`rule_set: usize`).
205    /// When a rule is deleted from the middle of a list, every rule
206    /// after it gets a new index, so its `NodeAddress` changes. The
207    /// GUI's NodeId must *not* change — that's the whole point of
208    /// UUIDs — so this function preserves the existing
209    /// address_to_id mapping where addresses still exist and only
210    /// mints new IDs for genuinely new addresses.
211    ///
212    /// For Step 1 there's nothing to preserve: load is a from-scratch
213    /// operation. Step 2 will call a more careful `reseed_after_edit`.
214    fn seed_ids(&mut self) {
215        // Root is always present.
216        self.ids.insert(NodeAddress::Root);
217
218        // Fallback respond dir is always present — even if the user
219        // hasn't set it, it has a default value.
220        self.ids.insert(NodeAddress::FallbackRespondDir);
221
222        // Rule sets + their rules + respond blocks.
223        for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
224            self.ids.insert(NodeAddress::RuleSet { rule_set: rs_idx });
225            for (rule_idx, _rule) in rule_set.rules.iter().enumerate() {
226                self.ids.insert(NodeAddress::Rule {
227                    rule_set: rs_idx,
228                    rule: rule_idx,
229                });
230                self.ids.insert(NodeAddress::Respond {
231                    rule_set: rs_idx,
232                    rule: rule_idx,
233                });
234            }
235        }
236
237        // Middleware references.
238        if let Some(paths) = self.config.service.middlewares_file_paths.as_ref() {
239            for mw_idx in 0..paths.len() {
240                self.ids
241                    .insert(NodeAddress::Middleware { middleware: mw_idx });
242            }
243        }
244    }
245
246    /// Build a snapshot for GUI rendering.
247    ///
248    /// # Allocation cost
249    ///
250    /// A snapshot fully owns its data (no borrows into the workspace)
251    /// so the GUI can serialise / send / store it without lifetime
252    /// gymnastics. This is O(total editable nodes) allocation per
253    /// call; the GUI should call it once per edit, not once per
254    /// render frame.
255    pub fn snapshot(&self) -> WorkspaceSnapshot {
256        let mut files: Vec<ConfigFileView> = Vec::new();
257
258        // Root file.
259        if let Some(root_nodes) = self.root_file_nodes() {
260            files.push(root_nodes);
261        }
262
263        // Rule-set files.
264        for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
265            files.push(self.rule_set_file_view(rs_idx, rule_set));
266        }
267
268        // Middleware files. We don't introspect them beyond their path
269        // existence; the Rhai AST is a server-side concern.
270        if let Some(paths) = self.config.service.middlewares_file_paths.as_ref() {
271            for (mw_idx, mw_path) in paths.iter().enumerate() {
272                let abs = self.resolve_relative(mw_path);
273                let id = self
274                    .ids
275                    .id_for(NodeAddress::Middleware { middleware: mw_idx })
276                    .expect("middleware id seeded at load");
277                let node = ConfigNodeView {
278                    id,
279                    source_file: abs.clone(),
280                    toml_path: format!("service.middlewares[{}]", mw_idx),
281                    display_name: mw_path.clone(),
282                    kind: NodeKind::Script,
283                    validation: NodeValidation::ok(),
284                };
285                files.push(ConfigFileView {
286                    path: abs.clone(),
287                    display_name: file_basename(&abs),
288                    kind: ConfigFileKind::Middleware,
289                    nodes: vec![node],
290                });
291            }
292        }
293
294        // Route catalog — placeholder for Step 5. Currently an empty
295        // snapshot; stage-2 of routing will populate.
296        let routes = apimock_routing::view::RouteCatalogSnapshot::empty();
297
298        WorkspaceSnapshot {
299            files,
300            routes,
301            diagnostics: self.diagnostics.clone(),
302        }
303    }
304
305    /// Apply one edit command, mutating the in-memory workspace.
306    ///
307    /// # Shape of the implementation
308    ///
309    /// Each `EditCommand` variant maps to a small helper method. The
310    /// helpers return `Result<Vec<NodeId>, ApplyError>`; `apply` wraps
311    /// the ok-path in an `ApplyResult` with the right `requires_reload`
312    /// flag and reruns validation so the result carries up-to-date
313    /// diagnostics.
314    ///
315    /// # ID stability on structural changes
316    ///
317    /// Commands that change positional layout (Remove / Delete / Move
318    /// / Add) touch `self.ids` carefully so NodeIds that refer to the
319    /// *same logical node* survive the operation. For example, after
320    /// `RemoveRuleSet { id }` at index `i`, rule sets at positions
321    /// `i+1..` shift down by one: the code below explicitly migrates
322    /// their IDs so a GUI that selected rule-set #3 before the edit
323    /// still has the same ID pointing at what is now rule-set #2.
324    pub fn apply(&mut self, cmd: EditCommand) -> Result<ApplyResult, ApplyError> {
325        let (changed_nodes, requires_reload) = match cmd {
326            EditCommand::AddRuleSet { path } => {
327                let ids = self.cmd_add_rule_set(path)?;
328                (ids, true)
329            }
330            EditCommand::RemoveRuleSet { id } => {
331                let ids = self.cmd_remove_rule_set(id)?;
332                (ids, true)
333            }
334            EditCommand::AddRule { parent, rule } => {
335                let ids = self.cmd_add_rule(parent, rule)?;
336                (ids, true)
337            }
338            EditCommand::UpdateRule { id, rule } => {
339                let ids = self.cmd_update_rule(id, rule)?;
340                (ids, true)
341            }
342            EditCommand::DeleteRule { id } => {
343                let ids = self.cmd_delete_rule(id)?;
344                (ids, true)
345            }
346            EditCommand::MoveRule { id, new_index } => {
347                let ids = self.cmd_move_rule(id, new_index)?;
348                (ids, true)
349            }
350            EditCommand::UpdateRespond { id, respond } => {
351                let ids = self.cmd_update_respond(id, respond)?;
352                (ids, true)
353            }
354            EditCommand::UpdateRootSetting { key, value } => {
355                let ids = self.cmd_update_root_setting(key, value)?;
356                // Root settings include listener port / ip, which change
357                // how the listener binds. Those need a full restart, not
358                // just a reload — the caller reads `reload_hint` from
359                // save() for the fine-grained hint; at apply time we
360                // conservatively flag `requires_reload = true`.
361                (ids, true)
362            }
363        };
364
365        // After any mutation, refresh per-node validation so the
366        // `ApplyResult.diagnostics` reflects the new state. This is the
367        // Step-3 piece: validation is now per-node and GUI-ready, not a
368        // bare boolean.
369        let diagnostics = self.collect_diagnostics();
370
371        Ok(ApplyResult {
372            changed_nodes,
373            diagnostics,
374            requires_reload,
375        })
376    }
377
378    // --- Individual command implementations --------------------------
379
380    fn cmd_add_rule_set(&mut self, path: String) -> Result<Vec<NodeId>, ApplyError> {
381        // Resolve the path against the root's parent dir (same
382        // convention as `Config::new`), then load the rule set.
383        let relative_dir = self.config_relative_dir().map_err(internal_path_err)?;
384        let joined = Path::new(&relative_dir).join(&path);
385        let path_str = joined.to_str().ok_or_else(|| ApplyError::InvalidPayload {
386            reason: format!(
387                "path contains non-UTF-8 bytes: {}",
388                joined.to_string_lossy()
389            ),
390        })?;
391
392        let next_idx = self.config.service.rule_sets.len();
393        let new_rule_set = RuleSet::new(path_str, relative_dir.as_str(), next_idx)
394            .map_err(|e| ApplyError::InvalidPayload {
395                reason: format!("failed to load rule set `{}`: {}", path, e),
396            })?;
397
398        // Record the path in service.rule_sets_file_paths too so
399        // `save()` persists the change later.
400        let file_paths = self
401            .config
402            .service
403            .rule_sets_file_paths
404            .get_or_insert_with(Vec::new);
405        file_paths.push(path.clone());
406
407        let new_len = self.config.service.rule_sets.len() + 1;
408        self.config.service.rule_sets.push(new_rule_set);
409
410        // Mint IDs for the new rule set + its rules + responds.
411        let rs_addr = NodeAddress::RuleSet {
412            rule_set: next_idx,
413        };
414        let rs_id = self.ids.insert(rs_addr);
415        let mut changed = vec![rs_id];
416        let new_rs = &self.config.service.rule_sets[next_idx];
417        for rule_idx in 0..new_rs.rules.len() {
418            let r_id = self.ids.insert(NodeAddress::Rule {
419                rule_set: next_idx,
420                rule: rule_idx,
421            });
422            let resp_id = self.ids.insert(NodeAddress::Respond {
423                rule_set: next_idx,
424                rule: rule_idx,
425            });
426            changed.push(r_id);
427            changed.push(resp_id);
428        }
429        // Sanity: new_len is purely informational here, but makes
430        // the invariant explicit to anyone reading the code.
431        debug_assert_eq!(new_len, self.config.service.rule_sets.len());
432
433        Ok(changed)
434    }
435
436    fn cmd_remove_rule_set(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
437        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
438        let NodeAddress::RuleSet { rule_set: idx } = addr else {
439            return Err(ApplyError::WrongNodeKind {
440                id,
441                reason: "expected a rule set id".to_owned(),
442            });
443        };
444
445        let len = self.config.service.rule_sets.len();
446        if idx >= len {
447            return Err(ApplyError::InvalidPayload {
448                reason: format!("rule set index {} out of range (len={})", idx, len),
449            });
450        }
451
452        // Collect IDs that will change: the removed one plus every rule
453        // set (+ rules + responds) whose index shifts down by one.
454        let mut changed: Vec<NodeId> = Vec::new();
455        // the rule-set itself and its internal nodes (removed)
456        changed.push(id);
457        if let Some(removed_rs) = self.config.service.rule_sets.get(idx) {
458            for rule_idx in 0..removed_rs.rules.len() {
459                if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
460                    rule_set: idx,
461                    rule: rule_idx,
462                }) {
463                    changed.push(r_id);
464                }
465                if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
466                    rule_set: idx,
467                    rule: rule_idx,
468                }) {
469                    changed.push(resp_id);
470                }
471            }
472        }
473
474        // Actually remove.
475        self.config.service.rule_sets.remove(idx);
476        if let Some(paths) = self.config.service.rule_sets_file_paths.as_mut() {
477            if idx < paths.len() {
478                paths.remove(idx);
479            }
480        }
481
482        // Migrate IDs: everything at `idx` onwards in the *old* layout
483        // needs its address remapped. The clean approach: gather the
484        // old (address → id) pairs we care about, clear the entries
485        // affected by the shift, re-insert with new addresses.
486        self.shift_rule_sets_down(idx);
487
488        // Every shifted rule set's ID remains valid but its address
489        // has changed; surface those IDs too so the GUI refreshes
490        // their position indicators.
491        for shifted_idx in idx..self.config.service.rule_sets.len() {
492            if let Some(shifted_id) = self
493                .ids
494                .id_for(NodeAddress::RuleSet {
495                    rule_set: shifted_idx,
496                })
497            {
498                if !changed.contains(&shifted_id) {
499                    changed.push(shifted_id);
500                }
501            }
502        }
503
504        Ok(changed)
505    }
506
507    fn cmd_add_rule(
508        &mut self,
509        parent: NodeId,
510        rule_payload: crate::view::RulePayload,
511    ) -> Result<Vec<NodeId>, ApplyError> {
512        let addr = self
513            .ids
514            .lookup(parent)
515            .ok_or(ApplyError::UnknownNode { id: parent })?;
516        let NodeAddress::RuleSet { rule_set: rs_idx } = addr else {
517            return Err(ApplyError::WrongNodeKind {
518                id: parent,
519                reason: "expected a rule set id (parent for AddRule must be a rule set)".to_owned(),
520            });
521        };
522
523        let rule_set = self
524            .config
525            .service
526            .rule_sets
527            .get_mut(rs_idx)
528            .ok_or_else(|| ApplyError::InvalidPayload {
529                reason: format!("rule set index {} out of range", rs_idx),
530            })?;
531
532        let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx)?;
533        let new_rule_idx = rule_set.rules.len();
534        rule_set.rules.push(new_rule);
535
536        let r_id = self.ids.insert(NodeAddress::Rule {
537            rule_set: rs_idx,
538            rule: new_rule_idx,
539        });
540        let resp_id = self.ids.insert(NodeAddress::Respond {
541            rule_set: rs_idx,
542            rule: new_rule_idx,
543        });
544        Ok(vec![parent, r_id, resp_id])
545    }
546
547    fn cmd_update_rule(
548        &mut self,
549        id: NodeId,
550        rule_payload: crate::view::RulePayload,
551    ) -> Result<Vec<NodeId>, ApplyError> {
552        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
553        let NodeAddress::Rule {
554            rule_set: rs_idx,
555            rule: rule_idx,
556        } = addr
557        else {
558            return Err(ApplyError::WrongNodeKind {
559                id,
560                reason: "expected a rule id".to_owned(),
561            });
562        };
563
564        let rule_set = self
565            .config
566            .service
567            .rule_sets
568            .get_mut(rs_idx)
569            .ok_or_else(|| ApplyError::InvalidPayload {
570                reason: format!("rule set index {} out of range", rs_idx),
571            })?;
572
573        let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx)?;
574        *rule_set
575            .rules
576            .get_mut(rule_idx)
577            .ok_or_else(|| ApplyError::InvalidPayload {
578                reason: format!("rule index {} out of range", rule_idx),
579            })? = new_rule;
580
581        let resp_id = self
582            .ids
583            .id_for(NodeAddress::Respond {
584                rule_set: rs_idx,
585                rule: rule_idx,
586            })
587            .unwrap_or_else(NodeId::new);
588        Ok(vec![id, resp_id])
589    }
590
591    fn cmd_delete_rule(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
592        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
593        let NodeAddress::Rule {
594            rule_set: rs_idx,
595            rule: rule_idx,
596        } = addr
597        else {
598            return Err(ApplyError::WrongNodeKind {
599                id,
600                reason: "expected a rule id".to_owned(),
601            });
602        };
603
604        let rule_set = self
605            .config
606            .service
607            .rule_sets
608            .get_mut(rs_idx)
609            .ok_or_else(|| ApplyError::InvalidPayload {
610                reason: format!("rule set index {} out of range", rs_idx),
611            })?;
612
613        if rule_idx >= rule_set.rules.len() {
614            return Err(ApplyError::InvalidPayload {
615                reason: format!("rule index {} out of range", rule_idx),
616            });
617        }
618
619        // Gather IDs that will change.
620        let mut changed: Vec<NodeId> = Vec::new();
621        changed.push(id);
622        if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
623            rule_set: rs_idx,
624            rule: rule_idx,
625        }) {
626            changed.push(resp_id);
627        }
628
629        rule_set.rules.remove(rule_idx);
630        self.shift_rules_down(rs_idx, rule_idx);
631
632        // Shifted rules' ids change their address but not their identity.
633        let new_rule_count = self.config.service.rule_sets[rs_idx].rules.len();
634        for shifted_idx in rule_idx..new_rule_count {
635            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
636                rule_set: rs_idx,
637                rule: shifted_idx,
638            }) {
639                if !changed.contains(&r_id) {
640                    changed.push(r_id);
641                }
642            }
643            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
644                rule_set: rs_idx,
645                rule: shifted_idx,
646            }) {
647                if !changed.contains(&resp_id) {
648                    changed.push(resp_id);
649                }
650            }
651        }
652
653        Ok(changed)
654    }
655
656    fn cmd_move_rule(&mut self, id: NodeId, new_index: usize) -> Result<Vec<NodeId>, ApplyError> {
657        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
658        let NodeAddress::Rule {
659            rule_set: rs_idx,
660            rule: old_idx,
661        } = addr
662        else {
663            return Err(ApplyError::WrongNodeKind {
664                id,
665                reason: "expected a rule id".to_owned(),
666            });
667        };
668
669        let rule_set = self
670            .config
671            .service
672            .rule_sets
673            .get_mut(rs_idx)
674            .ok_or_else(|| ApplyError::InvalidPayload {
675                reason: format!("rule set index {} out of range", rs_idx),
676            })?;
677
678        if old_idx >= rule_set.rules.len() || new_index >= rule_set.rules.len() {
679            return Err(ApplyError::InvalidPayload {
680                reason: format!(
681                    "move out of bounds: old_idx={}, new_index={}, len={}",
682                    old_idx,
683                    new_index,
684                    rule_set.rules.len()
685                ),
686            });
687        }
688        if old_idx == new_index {
689            return Ok(vec![id]);
690        }
691
692        // Do the move in `config`.
693        let rule = rule_set.rules.remove(old_idx);
694        rule_set.rules.insert(new_index, rule);
695
696        // Reshuffle IDs for all rules in this rule set: the simplest
697        // correct approach is to pull out all rule+respond IDs for
698        // this rule-set, reorder them to match the new slice order,
699        // and re-insert.
700        self.reorder_rule_ids(rs_idx, old_idx, new_index);
701
702        // Every rule in [min(old, new) .. max(old, new)] changed address;
703        // report their IDs so the GUI repaints.
704        let lo = old_idx.min(new_index);
705        let hi = old_idx.max(new_index);
706        let mut changed: Vec<NodeId> = Vec::new();
707        for idx in lo..=hi {
708            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
709                rule_set: rs_idx,
710                rule: idx,
711            }) {
712                changed.push(r_id);
713            }
714            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
715                rule_set: rs_idx,
716                rule: idx,
717            }) {
718                changed.push(resp_id);
719            }
720        }
721        Ok(changed)
722    }
723
724    fn cmd_update_respond(
725        &mut self,
726        id: NodeId,
727        respond: crate::view::RespondPayload,
728    ) -> Result<Vec<NodeId>, ApplyError> {
729        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
730        let NodeAddress::Respond {
731            rule_set: rs_idx,
732            rule: rule_idx,
733        } = addr
734        else {
735            return Err(ApplyError::WrongNodeKind {
736                id,
737                reason: "expected a respond id".to_owned(),
738            });
739        };
740
741        let rule = self
742            .config
743            .service
744            .rule_sets
745            .get_mut(rs_idx)
746            .and_then(|rs| rs.rules.get_mut(rule_idx))
747            .ok_or_else(|| ApplyError::InvalidPayload {
748                reason: format!(
749                    "rule at rule_set={}, rule={} not found",
750                    rs_idx, rule_idx
751                ),
752            })?;
753
754        rule.respond = build_respond_from_payload(respond);
755
756        // Re-run status-code derivation so the updated `status` field
757        // has its matching `StatusCode` stored.
758        let rule_set = &self.config.service.rule_sets[rs_idx];
759        let derived = rule_set.rules[rule_idx].compute_derived_fields(rule_set, rule_idx, rs_idx);
760        self.config.service.rule_sets[rs_idx].rules[rule_idx] = derived;
761
762        Ok(vec![id])
763    }
764
765    fn cmd_update_root_setting(
766        &mut self,
767        key: crate::view::RootSettingKey,
768        value: EditValue,
769    ) -> Result<Vec<NodeId>, ApplyError> {
770        use crate::view::RootSettingKey::*;
771
772        match key {
773            ListenerIpAddress => {
774                let s = value_as_string(&value)?;
775                let listener = self.config.listener.get_or_insert_with(Default::default);
776                listener.ip_address = s;
777            }
778            ListenerPort => {
779                let n = value_as_integer(&value)?;
780                if !(0..=u16::MAX as i64).contains(&n) {
781                    return Err(ApplyError::InvalidPayload {
782                        reason: format!("port {} not in 0..=65535", n),
783                    });
784                }
785                let listener = self.config.listener.get_or_insert_with(Default::default);
786                listener.port = n as u16;
787            }
788            ServiceFallbackRespondDir => {
789                let s = value_as_string(&value)?;
790                self.config.service.fallback_respond_dir = s;
791            }
792            ServiceStrategy => {
793                let s = value_as_string(&value)?;
794                // The only recognised strategy value today is
795                // "first_match". Anything else is rejected — if future
796                // strategies are added, extend this match.
797                match s.as_str() {
798                    "first_match" => {
799                        self.config.service.strategy =
800                            Some(apimock_routing::Strategy::FirstMatch);
801                    }
802                    other => {
803                        return Err(ApplyError::InvalidPayload {
804                            reason: format!("unknown strategy: {}", other),
805                        });
806                    }
807                }
808            }
809        }
810
811        // Root is a singleton; its NodeId is always the same.
812        let id = self
813            .ids
814            .id_for(NodeAddress::Root)
815            .expect("root id seeded at load");
816        Ok(vec![id])
817    }
818
819    // --- Shared helpers ----------------------------------------------
820
821    /// After a rule set is removed at `removed_idx`, migrate every ID
822    /// whose address referenced a later rule set to its new index.
823    fn shift_rule_sets_down(&mut self, removed_idx: usize) {
824        // Walk current layout (after removal). For each surviving
825        // rule_set at new index `new_idx`, the *old* index was
826        // `new_idx` if `new_idx < removed_idx` (no shift needed) or
827        // `new_idx + 1` if `new_idx >= removed_idx` (it shifted down).
828        // We rebuild mappings only for the shifted half.
829        let new_rs_count = self.config.service.rule_sets.len();
830
831        // First drop any stale ID entries for the removed index and
832        // for everything whose old address will be replaced.
833        // Collect stale (old) addresses first, then update `self.ids`.
834        let mut stale: Vec<NodeAddress> = Vec::new();
835        stale.push(NodeAddress::RuleSet {
836            rule_set: removed_idx,
837        });
838        // The old index range is [removed_idx, new_rs_count+1).
839        for old_idx in removed_idx..new_rs_count + 1 {
840            stale.push(NodeAddress::RuleSet { rule_set: old_idx });
841            // We don't know the old rule counts any more, so we walk
842            // the id index for matches.
843        }
844
845        // Safer approach: pull all entries whose address's rule_set
846        // field is >= removed_idx (both Rule and Respond and RuleSet),
847        // and rebuild them.
848        let mut to_migrate: Vec<(NodeId, NodeAddress)> = Vec::new();
849        for (&addr, &id) in self.ids.address_to_id.iter() {
850            match addr {
851                NodeAddress::RuleSet { rule_set } if rule_set >= removed_idx => {
852                    to_migrate.push((id, addr));
853                }
854                NodeAddress::Rule { rule_set, .. } if rule_set >= removed_idx => {
855                    to_migrate.push((id, addr));
856                }
857                NodeAddress::Respond { rule_set, .. } if rule_set >= removed_idx => {
858                    to_migrate.push((id, addr));
859                }
860                _ => {}
861            }
862        }
863
864        for (id, addr) in &to_migrate {
865            self.ids.address_to_id.remove(addr);
866            self.ids.id_to_address.remove(id);
867        }
868
869        // Re-insert with shifted addresses, skipping anything that
870        // belonged to the removed rule set.
871        for (id, addr) in to_migrate {
872            let new_addr = match addr {
873                NodeAddress::RuleSet { rule_set } => {
874                    if rule_set == removed_idx {
875                        continue; // removed outright
876                    }
877                    NodeAddress::RuleSet {
878                        rule_set: rule_set - 1,
879                    }
880                }
881                NodeAddress::Rule { rule_set, rule } => {
882                    if rule_set == removed_idx {
883                        continue;
884                    }
885                    NodeAddress::Rule {
886                        rule_set: rule_set - 1,
887                        rule,
888                    }
889                }
890                NodeAddress::Respond { rule_set, rule } => {
891                    if rule_set == removed_idx {
892                        continue;
893                    }
894                    NodeAddress::Respond {
895                        rule_set: rule_set - 1,
896                        rule,
897                    }
898                }
899                other => other,
900            };
901            self.ids.id_to_address.insert(id, new_addr);
902            self.ids.address_to_id.insert(new_addr, id);
903        }
904    }
905
906    /// After a rule is deleted from `rule_set_idx` at position
907    /// `removed_rule_idx`, shift IDs for later rules in the same set.
908    fn shift_rules_down(&mut self, rule_set_idx: usize, removed_rule_idx: usize) {
909        let mut to_migrate: Vec<(NodeId, NodeAddress)> = Vec::new();
910        for (&addr, &id) in self.ids.address_to_id.iter() {
911            match addr {
912                NodeAddress::Rule { rule_set, rule }
913                    if rule_set == rule_set_idx && rule >= removed_rule_idx =>
914                {
915                    to_migrate.push((id, addr));
916                }
917                NodeAddress::Respond { rule_set, rule }
918                    if rule_set == rule_set_idx && rule >= removed_rule_idx =>
919                {
920                    to_migrate.push((id, addr));
921                }
922                _ => {}
923            }
924        }
925
926        for (id, addr) in &to_migrate {
927            self.ids.address_to_id.remove(addr);
928            self.ids.id_to_address.remove(id);
929        }
930
931        for (id, addr) in to_migrate {
932            let new_addr = match addr {
933                NodeAddress::Rule { rule_set, rule } => {
934                    if rule == removed_rule_idx {
935                        continue;
936                    }
937                    NodeAddress::Rule {
938                        rule_set,
939                        rule: rule - 1,
940                    }
941                }
942                NodeAddress::Respond { rule_set, rule } => {
943                    if rule == removed_rule_idx {
944                        continue;
945                    }
946                    NodeAddress::Respond {
947                        rule_set,
948                        rule: rule - 1,
949                    }
950                }
951                other => other,
952            };
953            self.ids.id_to_address.insert(id, new_addr);
954            self.ids.address_to_id.insert(new_addr, id);
955        }
956    }
957
958    /// After a rule in `rule_set_idx` moves from `old_idx` to
959    /// `new_idx`, shuffle the IDs of every rule between those indices.
960    fn reorder_rule_ids(&mut self, rule_set_idx: usize, old_idx: usize, new_idx: usize) {
961        // Grab current mapping for all rules in this rule set.
962        let rule_count = self.config.service.rule_sets[rule_set_idx].rules.len();
963        let mut rule_ids: Vec<Option<NodeId>> = (0..rule_count)
964            .map(|r| {
965                self.ids.id_for(NodeAddress::Rule {
966                    rule_set: rule_set_idx,
967                    rule: r,
968                })
969            })
970            .collect();
971        let mut resp_ids: Vec<Option<NodeId>> = (0..rule_count)
972            .map(|r| {
973                self.ids.id_for(NodeAddress::Respond {
974                    rule_set: rule_set_idx,
975                    rule: r,
976                })
977            })
978            .collect();
979
980        // Before the config move, `rule_ids[old_idx]` held the moving
981        // rule's old ID. But the config mutation already happened —
982        // so the id_for lookups above are pre-migration (the ids
983        // didn't change), they simply don't match the new layout yet.
984        // We mimic the same move on `rule_ids`:
985        let moving_r = rule_ids.remove(old_idx);
986        rule_ids.insert(new_idx, moving_r);
987        let moving_resp = resp_ids.remove(old_idx);
988        resp_ids.insert(new_idx, moving_resp);
989
990        // Clear old mappings for these addresses and repopulate.
991        for r in 0..rule_count {
992            let rule_addr = NodeAddress::Rule {
993                rule_set: rule_set_idx,
994                rule: r,
995            };
996            let resp_addr = NodeAddress::Respond {
997                rule_set: rule_set_idx,
998                rule: r,
999            };
1000            if let Some(prev_id) = self.ids.address_to_id.remove(&rule_addr) {
1001                self.ids.id_to_address.remove(&prev_id);
1002            }
1003            if let Some(prev_id) = self.ids.address_to_id.remove(&resp_addr) {
1004                self.ids.id_to_address.remove(&prev_id);
1005            }
1006        }
1007        for (r, id_opt) in rule_ids.into_iter().enumerate() {
1008            let addr = NodeAddress::Rule {
1009                rule_set: rule_set_idx,
1010                rule: r,
1011            };
1012            let id = id_opt.unwrap_or_else(NodeId::new);
1013            self.ids.id_to_address.insert(id, addr);
1014            self.ids.address_to_id.insert(addr, id);
1015        }
1016        for (r, id_opt) in resp_ids.into_iter().enumerate() {
1017            let addr = NodeAddress::Respond {
1018                rule_set: rule_set_idx,
1019                rule: r,
1020            };
1021            let id = id_opt.unwrap_or_else(NodeId::new);
1022            self.ids.id_to_address.insert(id, addr);
1023            self.ids.address_to_id.insert(addr, id);
1024        }
1025    }
1026
1027    fn config_relative_dir(&self) -> Result<String, ConfigError> {
1028        self.config.current_dir_to_parent_dir_relative_path()
1029    }
1030
1031    /// Walk every node, asking it for its validation state, and return
1032    /// the flat list of issues. Used at apply-time and on demand from
1033    /// `validate()`.
1034    fn collect_diagnostics(&self) -> Vec<Diagnostic> {
1035        let mut out: Vec<Diagnostic> = Vec::new();
1036        for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
1037            for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
1038                let nv = respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx);
1039                if nv.ok {
1040                    continue;
1041                }
1042                let resp_id = self.ids.id_for(NodeAddress::Respond {
1043                    rule_set: rs_idx,
1044                    rule: rule_idx,
1045                });
1046                for issue in nv.issues {
1047                    out.push(Diagnostic {
1048                        node_id: resp_id,
1049                        file: Some(PathBuf::from(rule_set.file_path.as_str())),
1050                        severity: issue.severity,
1051                        message: issue.message,
1052                    });
1053                }
1054            }
1055        }
1056
1057        // Root-level check: fallback_respond_dir must exist.
1058        if !Path::new(self.config.service.fallback_respond_dir.as_str()).exists() {
1059            out.push(Diagnostic {
1060                node_id: self.ids.id_for(NodeAddress::FallbackRespondDir),
1061                file: Some(self.root_path.clone()),
1062                severity: Severity::Error,
1063                message: format!(
1064                    "fallback_respond_dir does not exist: {}",
1065                    self.config.service.fallback_respond_dir
1066                ),
1067            });
1068        }
1069
1070        out
1071    }
1072
1073    // --- Public API ----
1074
1075    /// Validate the workspace and return a GUI-ready report.
1076    ///
1077    /// Uses the same per-node checks `snapshot()` does so the numbers
1078    /// line up: a node rendered with a red underline in the snapshot
1079    /// will appear in `report.diagnostics` with the same message.
1080    pub fn validate(&self) -> ValidationReport {
1081        let diagnostics = self.collect_diagnostics();
1082        let is_valid = !diagnostics
1083            .iter()
1084            .any(|d| matches!(d.severity, Severity::Error));
1085        ValidationReport {
1086            diagnostics,
1087            is_valid,
1088        }
1089    }
1090
1091    /// Save the workspace back to disk.
1092    ///
1093    /// # Algorithm
1094    ///
1095    /// 1. Render each editable file (root + each rule set) to TOML text.
1096    /// 2. Compare against `baseline_files`. Files whose rendered output
1097    ///    is byte-identical to the baseline are skipped — the user's
1098    ///    formatting / comments survive untouched in that case.
1099    /// 3. For files that *do* differ, write atomically via
1100    ///    `tempfile::NamedTempFile::persist` (same-directory rename(2)
1101    ///    on POSIX, `MoveFileExW` on Windows). On any single-file
1102    ///    failure, the partial state is whatever rename(2)s have
1103    ///    already succeeded — see the type-level docstring on
1104    ///    `SaveError` for the rationale.
1105    /// 4. After all writes succeed, refresh `baseline_files` so a
1106    ///    subsequent save() won't re-write the same files needlessly.
1107    /// 5. Compute `DiffItem`s by node, comparing the in-memory state
1108    ///    to the load-time baseline (parsed; not text-diff).
1109    /// 6. Compute `requires_reload` / `requires_restart` from the set
1110    ///    of changed files: changes to `[listener]` need a restart,
1111    ///    everything else just a reload.
1112    ///
1113    /// # The "save loses comments" diagnostic
1114    ///
1115    /// Per the GUI spec §6 / §11, save is allowed to lose comments and
1116    /// formatting. We surface this as an `Info`-severity diagnostic
1117    /// the first time a save would actually overwrite a file that has
1118    /// non-trivial formatting (any file whose TOML round-trip is not
1119    /// byte-identical, which is essentially every hand-edited file).
1120    /// A polished GUI shows it once per session.
1121    pub fn save(&mut self) -> Result<SaveResult, SaveError> {
1122        // --- Render every file's new content -------------------------
1123        let new_root_toml = crate::toml_writer::render_apimock_toml(&self.config);
1124
1125        let mut rule_set_renders: Vec<(PathBuf, String)> = Vec::new();
1126        for rule_set in self.config.service.rule_sets.iter() {
1127            let path = PathBuf::from(rule_set.file_path.as_str());
1128            let text = crate::toml_writer::render_rule_set_toml(rule_set);
1129            rule_set_renders.push((path, text));
1130        }
1131
1132        // --- Compute changed-file set --------------------------------
1133        let mut to_write: Vec<(PathBuf, String)> = Vec::new();
1134
1135        let baseline_root = self.baseline_files.get(&self.root_path);
1136        if baseline_root.map(String::as_str) != Some(new_root_toml.as_str()) {
1137            to_write.push((self.root_path.clone(), new_root_toml.clone()));
1138        }
1139        for (path, text) in rule_set_renders.iter() {
1140            let baseline = self.baseline_files.get(path);
1141            if baseline.map(String::as_str) != Some(text.as_str()) {
1142                to_write.push((path.clone(), text.clone()));
1143            }
1144        }
1145
1146        // --- Atomic write via tempfile::persist ----------------------
1147        let mut written: Vec<PathBuf> = Vec::with_capacity(to_write.len());
1148        for (path, text) in &to_write {
1149            atomic_write(path, text)?;
1150            written.push(path.clone());
1151        }
1152
1153        // --- Build diff_summary BEFORE updating baseline ------------
1154        // The diff is "what did this save flush to disk", computed
1155        // against the *previous* baseline. Once we refresh the
1156        // baseline below, every node would compare equal again.
1157        let diff_summary = self.compute_diff_summary();
1158
1159        // --- Refresh baseline ---------------------------------------
1160        for (path, text) in to_write.into_iter() {
1161            self.baseline_files.insert(path, text);
1162        }
1163
1164        // --- Reload hint --------------------------------------------
1165        // If the root file (which holds [listener]) was rewritten we
1166        // conservatively flag a restart. Otherwise rule-set-only changes
1167        // are a plain reload.
1168        let listener_changed = written.contains(&self.root_path);
1169        let requires_reload = listener_changed || !written.is_empty();
1170
1171        Ok(SaveResult {
1172            changed_files: written,
1173            diff_summary,
1174            requires_reload,
1175        })
1176    }
1177
1178    /// Compute the diff summary for the most recent save: one entry
1179    /// per node whose rendered representation has changed since load.
1180    ///
1181    /// # Why this isn't a textual diff
1182    ///
1183    /// A line-by-line text diff would surface noise from formatting
1184    /// (key reordering, comment loss). The GUI wants to know which
1185    /// *logical* nodes the user changed — so we walk the node-address
1186    /// space, compare the in-memory state to a re-parsed snapshot of
1187    /// the baseline, and emit `DiffItem`s keyed by `NodeId`.
1188    ///
1189    /// 5.2.0 implements the comparison at rule-set granularity
1190    /// (Updated / Added / Removed) rather than per-rule. Per-rule
1191    /// diffing is a stage-5 candidate — it would require parsing the
1192    /// baseline TOML back into the in-memory model, which adds a
1193    /// second loader path. Stage-5 also benefits from the same parse
1194    /// for richer routing snapshots.
1195    fn compute_diff_summary(&self) -> Vec<crate::view::DiffItem> {
1196        use crate::view::{DiffItem, DiffKind};
1197
1198        let mut out = Vec::new();
1199
1200        // Did any rule set's rendered TOML diverge from baseline?
1201        for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
1202            let path = PathBuf::from(rule_set.file_path.as_str());
1203            let rendered = crate::toml_writer::render_rule_set_toml(rule_set);
1204            let baseline_matches = self
1205                .baseline_files
1206                .get(&path)
1207                .map(|s| s.as_str() == rendered.as_str())
1208                .unwrap_or(false);
1209            if !baseline_matches {
1210                if let Some(rs_id) = self.ids.id_for(NodeAddress::RuleSet { rule_set: rs_idx }) {
1211                    let kind = if self.baseline_files.contains_key(&path) {
1212                        DiffKind::Updated
1213                    } else {
1214                        DiffKind::Added
1215                    };
1216                    out.push(DiffItem {
1217                        kind,
1218                        target: rs_id,
1219                        summary: format!(
1220                            "rule set #{} ({}): rules={}",
1221                            rs_idx + 1,
1222                            file_basename(&path),
1223                            rule_set.rules.len(),
1224                        ),
1225                    });
1226                }
1227            }
1228        }
1229
1230        // Did the root file diverge?
1231        let root_rendered = crate::toml_writer::render_apimock_toml(&self.config);
1232        let root_baseline_matches = self
1233            .baseline_files
1234            .get(&self.root_path)
1235            .map(|s| s.as_str() == root_rendered.as_str())
1236            .unwrap_or(false);
1237        if !root_baseline_matches {
1238            if let Some(root_id) = self.ids.id_for(NodeAddress::Root) {
1239                out.push(DiffItem {
1240                    kind: DiffKind::Updated,
1241                    target: root_id,
1242                    summary: format!(
1243                        "{}: listener / log / service",
1244                        file_basename(&self.root_path)
1245                    ),
1246                });
1247            }
1248        }
1249
1250        out
1251    }
1252
1253    /// True when at least one editable file's rendered output differs
1254    /// from its load-time baseline.
1255    ///
1256    /// # Use case
1257    ///
1258    /// A GUI's "unsaved changes" indicator polls this. Cheap relative
1259    /// to a full save (no file I/O, just renders + string compares).
1260    pub fn has_unsaved_changes(&self) -> bool {
1261        let root_text = crate::toml_writer::render_apimock_toml(&self.config);
1262        if self
1263            .baseline_files
1264            .get(&self.root_path)
1265            .map(|s| s.as_str())
1266            != Some(root_text.as_str())
1267        {
1268            return true;
1269        }
1270        for rule_set in self.config.service.rule_sets.iter() {
1271            let path = PathBuf::from(rule_set.file_path.as_str());
1272            let text = crate::toml_writer::render_rule_set_toml(rule_set);
1273            if self
1274                .baseline_files
1275                .get(&path)
1276                .map(|s| s.as_str())
1277                != Some(text.as_str())
1278            {
1279                return true;
1280            }
1281        }
1282        false
1283    }
1284
1285    /// Root config file as a `ConfigFileView`, if it can be rendered.
1286    fn root_file_nodes(&self) -> Option<ConfigFileView> {
1287        let mut nodes = Vec::new();
1288
1289        if let Some(root_id) = self.ids.id_for(NodeAddress::Root) {
1290            nodes.push(ConfigNodeView {
1291                id: root_id,
1292                source_file: self.root_path.clone(),
1293                toml_path: String::new(),
1294                display_name: "apimock.toml".to_owned(),
1295                kind: NodeKind::RootSetting,
1296                validation: NodeValidation::ok(),
1297            });
1298        }
1299
1300        if let Some(fb_id) = self.ids.id_for(NodeAddress::FallbackRespondDir) {
1301            nodes.push(ConfigNodeView {
1302                id: fb_id,
1303                source_file: self.root_path.clone(),
1304                toml_path: "service.fallback_respond_dir".to_owned(),
1305                display_name: self.config.service.fallback_respond_dir.clone(),
1306                kind: NodeKind::FileNode,
1307                validation: NodeValidation::ok(),
1308            });
1309        }
1310
1311        Some(ConfigFileView {
1312            path: self.root_path.clone(),
1313            display_name: file_basename(&self.root_path),
1314            kind: ConfigFileKind::Root,
1315            nodes,
1316        })
1317    }
1318
1319    fn rule_set_file_view(&self, rs_idx: usize, rule_set: &RuleSet) -> ConfigFileView {
1320        let file_path = PathBuf::from(rule_set.file_path.as_str());
1321        let mut nodes: Vec<ConfigNodeView> = Vec::new();
1322
1323        // Rule-set itself.
1324        if let Some(rs_id) = self
1325            .ids
1326            .id_for(NodeAddress::RuleSet { rule_set: rs_idx })
1327        {
1328            nodes.push(ConfigNodeView {
1329                id: rs_id,
1330                source_file: file_path.clone(),
1331                toml_path: String::new(),
1332                display_name: file_basename(&file_path),
1333                kind: NodeKind::RuleSet,
1334                validation: NodeValidation::ok(),
1335            });
1336        }
1337
1338        // Rules inside.
1339        for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
1340            if let Some(rule_id) = self.ids.id_for(NodeAddress::Rule {
1341                rule_set: rs_idx,
1342                rule: rule_idx,
1343            }) {
1344                let url_path_label = rule
1345                    .when
1346                    .request
1347                    .url_path
1348                    .as_ref()
1349                    .map(|u| u.value.as_str())
1350                    .unwrap_or_default();
1351                let display = if url_path_label.is_empty() {
1352                    format!("Rule #{}", rule_idx + 1)
1353                } else {
1354                    url_path_label.to_owned()
1355                };
1356                nodes.push(ConfigNodeView {
1357                    id: rule_id,
1358                    source_file: file_path.clone(),
1359                    toml_path: format!("rules[{}]", rule_idx),
1360                    display_name: display,
1361                    kind: NodeKind::Rule,
1362                    validation: NodeValidation::ok(),
1363                });
1364            }
1365
1366            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
1367                rule_set: rs_idx,
1368                rule: rule_idx,
1369            }) {
1370                nodes.push(ConfigNodeView {
1371                    id: resp_id,
1372                    source_file: file_path.clone(),
1373                    toml_path: format!("rules[{}].respond", rule_idx),
1374                    display_name: summarise_respond(&rule.respond),
1375                    kind: NodeKind::Respond,
1376                    validation: respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx),
1377                });
1378            }
1379        }
1380
1381        ConfigFileView {
1382            path: file_path.clone(),
1383            display_name: file_basename(&file_path),
1384            kind: ConfigFileKind::RuleSet,
1385            nodes,
1386        }
1387    }
1388
1389    fn resolve_relative(&self, rel: &str) -> PathBuf {
1390        match self.config.current_dir_to_parent_dir_relative_path() {
1391            Ok(dir) => Path::new(&dir).join(rel),
1392            Err(_) => PathBuf::from(rel),
1393        }
1394    }
1395
1396    /// Access the underlying `Config`. Intended for embedders that
1397    /// need to build a running `Server` from the same workspace. Edit
1398    /// via `apply()` instead of touching `Config` directly — changes
1399    /// made through this reference are invisible to the ID index.
1400    pub fn config(&self) -> &Config {
1401        &self.config
1402    }
1403
1404    /// Access the root path. Primarily for diagnostics.
1405    pub fn root_path(&self) -> &Path {
1406        &self.root_path
1407    }
1408}
1409
1410/// Collapse a `Respond` into a one-line display label.
1411fn summarise_respond(respond: &apimock_routing::Respond) -> String {
1412    if let Some(p) = respond.file_path.as_ref() {
1413        return format!("file: {}", p);
1414    }
1415    if let Some(t) = respond.text.as_ref() {
1416        const LIMIT: usize = 40;
1417        if t.chars().count() > LIMIT {
1418            let truncated: String = t.chars().take(LIMIT).collect();
1419            return format!("text: {}…", truncated);
1420        }
1421        return format!("text: {}", t);
1422    }
1423    if let Some(s) = respond.status.as_ref() {
1424        return format!("status: {}", s);
1425    }
1426    "(empty)".to_owned()
1427}
1428
1429fn respond_node_validation(
1430    respond: &apimock_routing::Respond,
1431    rule_set: &RuleSet,
1432    rule_idx: usize,
1433    rs_idx: usize,
1434) -> NodeValidation {
1435    // `Respond::validate` logs errors but returns a bool. For 5.1
1436    // per-node validation we want structured messages — so we replicate
1437    // the specific checks here rather than piping through the logger.
1438    let mut issues: Vec<ValidationIssue> = Vec::new();
1439
1440    let any = respond.file_path.is_some() || respond.text.is_some() || respond.status.is_some();
1441    if !any {
1442        issues.push(ValidationIssue {
1443            severity: Severity::Error,
1444            message: "response requires at least one of file_path, text, or status".to_owned(),
1445        });
1446    }
1447    if respond.file_path.is_some() && respond.text.is_some() {
1448        issues.push(ValidationIssue {
1449            severity: Severity::Error,
1450            message: "file_path and text cannot both be set".to_owned(),
1451        });
1452    }
1453    if respond.file_path.is_some() && respond.status.is_some() {
1454        issues.push(ValidationIssue {
1455            severity: Severity::Error,
1456            message: "status cannot be combined with file_path (only with text)".to_owned(),
1457        });
1458    }
1459
1460    // file-existence validation: this is the same behaviour the old
1461    // `Respond::validate(dir_prefix, …)` performed. We don't call it
1462    // directly because it writes to `log::error!`, which would flood
1463    // the console during every GUI snapshot.
1464    if let Some(file_path) = respond.file_path.as_ref() {
1465        let dir_prefix = rule_set.dir_prefix();
1466        let p = Path::new(dir_prefix.as_str()).join(file_path);
1467        if !p.exists() {
1468            issues.push(ValidationIssue {
1469                severity: Severity::Error,
1470                message: format!(
1471                    "file not found: {} (rule #{} in rule set #{})",
1472                    p.to_string_lossy(),
1473                    rule_idx + 1,
1474                    rs_idx + 1,
1475                ),
1476            });
1477        }
1478    }
1479
1480    NodeValidation {
1481        ok: issues.is_empty(),
1482        issues,
1483    }
1484}
1485
1486fn file_basename(path: &Path) -> String {
1487    path.file_name()
1488        .map(|n| n.to_string_lossy().into_owned())
1489        .unwrap_or_else(|| path.to_string_lossy().into_owned())
1490}
1491
1492/// Write `text` to `path` atomically.
1493///
1494/// # Why a tempfile + persist instead of a direct write
1495///
1496/// `std::fs::write` is two syscalls (truncate + write) with a window
1497/// between them where a concurrent reader can see an empty file. The
1498/// running apimock server reads its own config files when (eventually)
1499/// it supports reload; if it picks a moment in the middle of
1500/// `std::fs::write`, it can fail to parse a half-written TOML.
1501///
1502/// `tempfile::NamedTempFile::persist` writes to `<dir>/.tmpXXXX`,
1503/// `fsync`s, then `rename(2)`s onto the destination — a single
1504/// directory-entry update that the kernel guarantees is atomic. On
1505/// Windows, `tempfile` translates this into `MoveFileExW` with the
1506/// replace-existing flag for the same effect.
1507///
1508/// # Error mapping
1509///
1510/// `tempfile`'s persist returns a `PersistError` that wraps both the
1511/// `NamedTempFile` and the underlying `io::Error`. We unwrap the
1512/// `io::Error` and surface it as `SaveError::Write`. The temp file
1513/// is dropped automatically (and removed) when the persist error
1514/// returns.
1515fn atomic_write(path: &Path, text: &str) -> Result<(), SaveError> {
1516    let parent = path
1517        .parent()
1518        .filter(|p| !p.as_os_str().is_empty())
1519        .map(Path::to_path_buf)
1520        .unwrap_or_else(|| PathBuf::from("."));
1521
1522    let mut tmp =
1523        tempfile::NamedTempFile::new_in(&parent).map_err(|e| SaveError::Write {
1524            path: path.to_path_buf(),
1525            source: e,
1526        })?;
1527
1528    use std::io::Write;
1529    tmp.write_all(text.as_bytes())
1530        .map_err(|e| SaveError::Write {
1531            path: path.to_path_buf(),
1532            source: e,
1533        })?;
1534    tmp.flush().map_err(|e| SaveError::Write {
1535        path: path.to_path_buf(),
1536        source: e,
1537    })?;
1538
1539    tmp.persist(path).map_err(|persist_err| SaveError::Write {
1540        path: path.to_path_buf(),
1541        source: persist_err.error,
1542    })?;
1543    Ok(())
1544}
1545
1546// --- Payload → model helpers used by the apply layer --------------
1547
1548fn build_rule_from_payload(
1549    payload: crate::view::RulePayload,
1550    rule_set: &apimock_routing::RuleSet,
1551    rs_idx: usize,
1552) -> Result<apimock_routing::Rule, ApplyError> {
1553    use apimock_routing::rule_set::rule::Rule;
1554    use apimock_routing::rule_set::rule::when::When;
1555    use apimock_routing::rule_set::rule::when::request::{
1556        Request, http_method::HttpMethod, url_path::UrlPathConfig,
1557    };
1558
1559    // Build the Request shape from the simple payload. We use the
1560    // simple UrlPath variant (Simple(String)) because the payload's
1561    // url_path is a plain string; the richer variants (op, etc.) are
1562    // out of scope for 5.1 — a GUI can round-trip them once Step-5
1563    // exposes richer form controls.
1564    let url_path_config = payload.url_path.as_ref().map(|s| UrlPathConfig::Simple(s.clone()));
1565
1566    let http_method = match payload.method.as_deref() {
1567        Some("GET") | Some("get") => Some(HttpMethod::Get),
1568        Some("POST") | Some("post") => Some(HttpMethod::Post),
1569        Some("PUT") | Some("put") => Some(HttpMethod::Put),
1570        Some("DELETE") | Some("delete") => Some(HttpMethod::Delete),
1571        Some(other) => {
1572            return Err(ApplyError::InvalidPayload {
1573                reason: format!(
1574                    "unsupported HTTP method `{}` — supported: GET, POST, PUT, DELETE",
1575                    other
1576                ),
1577            });
1578        }
1579        None => None,
1580    };
1581
1582    let request = Request {
1583        url_path_config,
1584        url_path: None, // derived below
1585        http_method,
1586        headers: None,
1587        body: None,
1588    };
1589
1590    let rule = Rule {
1591        when: When { request },
1592        respond: build_respond_from_payload(payload.respond),
1593    };
1594
1595    // compute_derived_fields normalises the URL path with the rule
1596    // set's prefix and validates the status code. Running it here means
1597    // the freshly-created rule is ready for matching without a second
1598    // pass.
1599    //
1600    // `rule_idx` at this point is whatever position the rule will
1601    // occupy after being pushed — use `rule_set.rules.len()` because
1602    // the push happens immediately after.
1603    Ok(rule.compute_derived_fields(rule_set, rule_set.rules.len(), rs_idx))
1604}
1605
1606fn build_respond_from_payload(payload: crate::view::RespondPayload) -> apimock_routing::Respond {
1607    apimock_routing::Respond {
1608        file_path: payload.file_path,
1609        csv_records_key: None,
1610        text: payload.text,
1611        status: payload.status,
1612        status_code: None, // derived later
1613        headers: None,
1614        delay_response_milliseconds: payload.delay_milliseconds,
1615    }
1616}
1617
1618fn value_as_string(value: &EditValue) -> Result<String, ApplyError> {
1619    match value {
1620        EditValue::String(s) => Ok(s.clone()),
1621        EditValue::Enum(s) => Ok(s.clone()),
1622        other => Err(ApplyError::InvalidPayload {
1623            reason: format!("expected a string, got {:?}", other),
1624        }),
1625    }
1626}
1627
1628fn value_as_integer(value: &EditValue) -> Result<i64, ApplyError> {
1629    match value {
1630        EditValue::Integer(n) => Ok(*n),
1631        other => Err(ApplyError::InvalidPayload {
1632            reason: format!("expected an integer, got {:?}", other),
1633        }),
1634    }
1635}
1636
1637/// Wrap a ConfigError produced inside an apply command as an
1638/// `ApplyError::InvalidPayload`. Apply uses anyhow-ish flattening
1639/// because the caller doesn't care whether the root cause was a
1640/// read-fail or a parse-fail — they all surface as "edit couldn't
1641/// be applied" from the GUI's point of view.
1642fn internal_path_err(err: ConfigError) -> ApplyError {
1643    ApplyError::InvalidPayload {
1644        reason: format!("internal path resolution failed: {}", err),
1645    }
1646}
1647
1648fn resolve_root(root: &Path) -> Result<PathBuf, WorkspaceError> {
1649    if root.is_file() {
1650        return Ok(root.to_path_buf());
1651    }
1652    if root.is_dir() {
1653        let candidate = root.join("apimock.toml");
1654        if candidate.is_file() {
1655            return Ok(candidate);
1656        }
1657        return Err(WorkspaceError::InvalidRoot {
1658            path: root.to_path_buf(),
1659            reason: "directory does not contain apimock.toml".to_owned(),
1660        });
1661    }
1662    Err(WorkspaceError::InvalidRoot {
1663        path: root.to_path_buf(),
1664        reason: "path does not exist".to_owned(),
1665    })
1666}
1667
1668// Convert a raw `RoutingError` sneaked into the load path; normally
1669// `ConfigError` wraps it, but the explicit conversion keeps the
1670// apply-layer clean when it needs to materialise one.
1671#[allow(dead_code)]
1672fn routing_to_config(err: RoutingError) -> ConfigError {
1673    ConfigError::from(err)
1674}
1675
1676#[cfg(test)]
1677mod tests;