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 — assemble from rule sets, fallback dir,
295        // file tree (depth-1 eager), and middleware script routes.
296        // Builders live in `apimock_routing::view::build`; the config
297        // crate just feeds them the data they need.
298        let fallback_dir = self.config.service.fallback_respond_dir.as_str();
299        let fallback_abs = self.resolve_relative(fallback_dir);
300        let file_tree = apimock_routing::view::build::build_file_tree(&fallback_abs);
301
302        let script_routes: Vec<apimock_routing::view::ScriptRouteView> = self
303            .config
304            .service
305            .middlewares_file_paths
306            .as_ref()
307            .map(|paths| {
308                paths
309                    .iter()
310                    .enumerate()
311                    .map(|(idx, p)| apimock_routing::view::build::build_script_route_view(idx, p))
312                    .collect()
313            })
314            .unwrap_or_default();
315
316        let routes = apimock_routing::view::build::build_route_catalog(
317            &self.config.service.rule_sets,
318            Some(fallback_dir),
319            file_tree,
320            script_routes,
321        );
322
323        WorkspaceSnapshot {
324            files,
325            routes,
326            diagnostics: self.diagnostics.clone(),
327        }
328    }
329
330    /// Apply one edit command, mutating the in-memory workspace.
331    ///
332    /// # Shape of the implementation
333    ///
334    /// Each `EditCommand` variant maps to a small helper method. The
335    /// helpers return `Result<Vec<NodeId>, ApplyError>`; `apply` wraps
336    /// the ok-path in an `ApplyResult` with the right `requires_reload`
337    /// flag and reruns validation so the result carries up-to-date
338    /// diagnostics.
339    ///
340    /// # ID stability on structural changes
341    ///
342    /// Commands that change positional layout (Remove / Delete / Move
343    /// / Add) touch `self.ids` carefully so NodeIds that refer to the
344    /// *same logical node* survive the operation. For example, after
345    /// `RemoveRuleSet { id }` at index `i`, rule sets at positions
346    /// `i+1..` shift down by one: the code below explicitly migrates
347    /// their IDs so a GUI that selected rule-set #3 before the edit
348    /// still has the same ID pointing at what is now rule-set #2.
349    pub fn apply(&mut self, cmd: EditCommand) -> Result<ApplyResult, ApplyError> {
350        let (changed_nodes, requires_reload) = match cmd {
351            EditCommand::AddRuleSet { path } => {
352                let ids = self.cmd_add_rule_set(path)?;
353                (ids, true)
354            }
355            EditCommand::RemoveRuleSet { id } => {
356                let ids = self.cmd_remove_rule_set(id)?;
357                (ids, true)
358            }
359            EditCommand::AddRule { parent, rule } => {
360                let ids = self.cmd_add_rule(parent, rule)?;
361                (ids, true)
362            }
363            EditCommand::UpdateRule { id, rule } => {
364                let ids = self.cmd_update_rule(id, rule)?;
365                (ids, true)
366            }
367            EditCommand::DeleteRule { id } => {
368                let ids = self.cmd_delete_rule(id)?;
369                (ids, true)
370            }
371            EditCommand::MoveRule { id, new_index } => {
372                let ids = self.cmd_move_rule(id, new_index)?;
373                (ids, true)
374            }
375            EditCommand::UpdateRespond { id, respond } => {
376                let ids = self.cmd_update_respond(id, respond)?;
377                (ids, true)
378            }
379            EditCommand::UpdateRootSetting { key, value } => {
380                let ids = self.cmd_update_root_setting(key, value)?;
381                // Root settings include listener port / ip, which change
382                // how the listener binds. Those need a full restart, not
383                // just a reload — the caller reads `reload_hint` from
384                // save() for the fine-grained hint; at apply time we
385                // conservatively flag `requires_reload = true`.
386                (ids, true)
387            }
388        };
389
390        // After any mutation, refresh per-node validation so the
391        // `ApplyResult.diagnostics` reflects the new state. This is the
392        // Step-3 piece: validation is now per-node and GUI-ready, not a
393        // bare boolean.
394        let diagnostics = self.collect_diagnostics();
395
396        Ok(ApplyResult {
397            changed_nodes,
398            diagnostics,
399            requires_reload,
400        })
401    }
402
403    // --- Individual command implementations --------------------------
404
405    fn cmd_add_rule_set(&mut self, path: String) -> Result<Vec<NodeId>, ApplyError> {
406        // Resolve the path against the root's parent dir (same
407        // convention as `Config::new`), then load the rule set.
408        let relative_dir = self.config_relative_dir().map_err(internal_path_err)?;
409        let joined = Path::new(&relative_dir).join(&path);
410        let path_str = joined.to_str().ok_or_else(|| ApplyError::InvalidPayload {
411            reason: format!(
412                "path contains non-UTF-8 bytes: {}",
413                joined.to_string_lossy()
414            ),
415        })?;
416
417        let next_idx = self.config.service.rule_sets.len();
418        let new_rule_set = RuleSet::new(path_str, relative_dir.as_str(), next_idx)
419            .map_err(|e| ApplyError::InvalidPayload {
420                reason: format!("failed to load rule set `{}`: {}", path, e),
421            })?;
422
423        // Record the path in service.rule_sets_file_paths too so
424        // `save()` persists the change later.
425        let file_paths = self
426            .config
427            .service
428            .rule_sets_file_paths
429            .get_or_insert_with(Vec::new);
430        file_paths.push(path.clone());
431
432        let new_len = self.config.service.rule_sets.len() + 1;
433        self.config.service.rule_sets.push(new_rule_set);
434
435        // Mint IDs for the new rule set + its rules + responds.
436        let rs_addr = NodeAddress::RuleSet {
437            rule_set: next_idx,
438        };
439        let rs_id = self.ids.insert(rs_addr);
440        let mut changed = vec![rs_id];
441        let new_rs = &self.config.service.rule_sets[next_idx];
442        for rule_idx in 0..new_rs.rules.len() {
443            let r_id = self.ids.insert(NodeAddress::Rule {
444                rule_set: next_idx,
445                rule: rule_idx,
446            });
447            let resp_id = self.ids.insert(NodeAddress::Respond {
448                rule_set: next_idx,
449                rule: rule_idx,
450            });
451            changed.push(r_id);
452            changed.push(resp_id);
453        }
454        // Sanity: new_len is purely informational here, but makes
455        // the invariant explicit to anyone reading the code.
456        debug_assert_eq!(new_len, self.config.service.rule_sets.len());
457
458        Ok(changed)
459    }
460
461    fn cmd_remove_rule_set(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
462        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
463        let NodeAddress::RuleSet { rule_set: idx } = addr else {
464            return Err(ApplyError::WrongNodeKind {
465                id,
466                reason: "expected a rule set id".to_owned(),
467            });
468        };
469
470        let len = self.config.service.rule_sets.len();
471        if idx >= len {
472            return Err(ApplyError::InvalidPayload {
473                reason: format!("rule set index {} out of range (len={})", idx, len),
474            });
475        }
476
477        // Collect IDs that will change: the removed one plus every rule
478        // set (+ rules + responds) whose index shifts down by one.
479        let mut changed: Vec<NodeId> = Vec::new();
480        // the rule-set itself and its internal nodes (removed)
481        changed.push(id);
482        if let Some(removed_rs) = self.config.service.rule_sets.get(idx) {
483            for rule_idx in 0..removed_rs.rules.len() {
484                if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
485                    rule_set: idx,
486                    rule: rule_idx,
487                }) {
488                    changed.push(r_id);
489                }
490                if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
491                    rule_set: idx,
492                    rule: rule_idx,
493                }) {
494                    changed.push(resp_id);
495                }
496            }
497        }
498
499        // Actually remove.
500        self.config.service.rule_sets.remove(idx);
501        if let Some(paths) = self.config.service.rule_sets_file_paths.as_mut() {
502            if idx < paths.len() {
503                paths.remove(idx);
504            }
505        }
506
507        // Migrate IDs: everything at `idx` onwards in the *old* layout
508        // needs its address remapped. The clean approach: gather the
509        // old (address → id) pairs we care about, clear the entries
510        // affected by the shift, re-insert with new addresses.
511        self.shift_rule_sets_down(idx);
512
513        // Every shifted rule set's ID remains valid but its address
514        // has changed; surface those IDs too so the GUI refreshes
515        // their position indicators.
516        for shifted_idx in idx..self.config.service.rule_sets.len() {
517            if let Some(shifted_id) = self
518                .ids
519                .id_for(NodeAddress::RuleSet {
520                    rule_set: shifted_idx,
521                })
522            {
523                if !changed.contains(&shifted_id) {
524                    changed.push(shifted_id);
525                }
526            }
527        }
528
529        Ok(changed)
530    }
531
532    fn cmd_add_rule(
533        &mut self,
534        parent: NodeId,
535        rule_payload: crate::view::RulePayload,
536    ) -> Result<Vec<NodeId>, ApplyError> {
537        let addr = self
538            .ids
539            .lookup(parent)
540            .ok_or(ApplyError::UnknownNode { id: parent })?;
541        let NodeAddress::RuleSet { rule_set: rs_idx } = addr else {
542            return Err(ApplyError::WrongNodeKind {
543                id: parent,
544                reason: "expected a rule set id (parent for AddRule must be a rule set)".to_owned(),
545            });
546        };
547
548        let rule_set = self
549            .config
550            .service
551            .rule_sets
552            .get_mut(rs_idx)
553            .ok_or_else(|| ApplyError::InvalidPayload {
554                reason: format!("rule set index {} out of range", rs_idx),
555            })?;
556
557        let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx)?;
558        let new_rule_idx = rule_set.rules.len();
559        rule_set.rules.push(new_rule);
560
561        let r_id = self.ids.insert(NodeAddress::Rule {
562            rule_set: rs_idx,
563            rule: new_rule_idx,
564        });
565        let resp_id = self.ids.insert(NodeAddress::Respond {
566            rule_set: rs_idx,
567            rule: new_rule_idx,
568        });
569        Ok(vec![parent, r_id, resp_id])
570    }
571
572    fn cmd_update_rule(
573        &mut self,
574        id: NodeId,
575        rule_payload: crate::view::RulePayload,
576    ) -> Result<Vec<NodeId>, ApplyError> {
577        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
578        let NodeAddress::Rule {
579            rule_set: rs_idx,
580            rule: rule_idx,
581        } = addr
582        else {
583            return Err(ApplyError::WrongNodeKind {
584                id,
585                reason: "expected a rule id".to_owned(),
586            });
587        };
588
589        let rule_set = self
590            .config
591            .service
592            .rule_sets
593            .get_mut(rs_idx)
594            .ok_or_else(|| ApplyError::InvalidPayload {
595                reason: format!("rule set index {} out of range", rs_idx),
596            })?;
597
598        let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx)?;
599        *rule_set
600            .rules
601            .get_mut(rule_idx)
602            .ok_or_else(|| ApplyError::InvalidPayload {
603                reason: format!("rule index {} out of range", rule_idx),
604            })? = new_rule;
605
606        let resp_id = self
607            .ids
608            .id_for(NodeAddress::Respond {
609                rule_set: rs_idx,
610                rule: rule_idx,
611            })
612            .unwrap_or_else(NodeId::new);
613        Ok(vec![id, resp_id])
614    }
615
616    fn cmd_delete_rule(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
617        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
618        let NodeAddress::Rule {
619            rule_set: rs_idx,
620            rule: rule_idx,
621        } = addr
622        else {
623            return Err(ApplyError::WrongNodeKind {
624                id,
625                reason: "expected a rule id".to_owned(),
626            });
627        };
628
629        let rule_set = self
630            .config
631            .service
632            .rule_sets
633            .get_mut(rs_idx)
634            .ok_or_else(|| ApplyError::InvalidPayload {
635                reason: format!("rule set index {} out of range", rs_idx),
636            })?;
637
638        if rule_idx >= rule_set.rules.len() {
639            return Err(ApplyError::InvalidPayload {
640                reason: format!("rule index {} out of range", rule_idx),
641            });
642        }
643
644        // Gather IDs that will change.
645        let mut changed: Vec<NodeId> = Vec::new();
646        changed.push(id);
647        if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
648            rule_set: rs_idx,
649            rule: rule_idx,
650        }) {
651            changed.push(resp_id);
652        }
653
654        rule_set.rules.remove(rule_idx);
655        self.shift_rules_down(rs_idx, rule_idx);
656
657        // Shifted rules' ids change their address but not their identity.
658        let new_rule_count = self.config.service.rule_sets[rs_idx].rules.len();
659        for shifted_idx in rule_idx..new_rule_count {
660            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
661                rule_set: rs_idx,
662                rule: shifted_idx,
663            }) {
664                if !changed.contains(&r_id) {
665                    changed.push(r_id);
666                }
667            }
668            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
669                rule_set: rs_idx,
670                rule: shifted_idx,
671            }) {
672                if !changed.contains(&resp_id) {
673                    changed.push(resp_id);
674                }
675            }
676        }
677
678        Ok(changed)
679    }
680
681    fn cmd_move_rule(&mut self, id: NodeId, new_index: usize) -> Result<Vec<NodeId>, ApplyError> {
682        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
683        let NodeAddress::Rule {
684            rule_set: rs_idx,
685            rule: old_idx,
686        } = addr
687        else {
688            return Err(ApplyError::WrongNodeKind {
689                id,
690                reason: "expected a rule id".to_owned(),
691            });
692        };
693
694        let rule_set = self
695            .config
696            .service
697            .rule_sets
698            .get_mut(rs_idx)
699            .ok_or_else(|| ApplyError::InvalidPayload {
700                reason: format!("rule set index {} out of range", rs_idx),
701            })?;
702
703        if old_idx >= rule_set.rules.len() || new_index >= rule_set.rules.len() {
704            return Err(ApplyError::InvalidPayload {
705                reason: format!(
706                    "move out of bounds: old_idx={}, new_index={}, len={}",
707                    old_idx,
708                    new_index,
709                    rule_set.rules.len()
710                ),
711            });
712        }
713        if old_idx == new_index {
714            return Ok(vec![id]);
715        }
716
717        // Do the move in `config`.
718        let rule = rule_set.rules.remove(old_idx);
719        rule_set.rules.insert(new_index, rule);
720
721        // Reshuffle IDs for all rules in this rule set: the simplest
722        // correct approach is to pull out all rule+respond IDs for
723        // this rule-set, reorder them to match the new slice order,
724        // and re-insert.
725        self.reorder_rule_ids(rs_idx, old_idx, new_index);
726
727        // Every rule in [min(old, new) .. max(old, new)] changed address;
728        // report their IDs so the GUI repaints.
729        let lo = old_idx.min(new_index);
730        let hi = old_idx.max(new_index);
731        let mut changed: Vec<NodeId> = Vec::new();
732        for idx in lo..=hi {
733            if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
734                rule_set: rs_idx,
735                rule: idx,
736            }) {
737                changed.push(r_id);
738            }
739            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
740                rule_set: rs_idx,
741                rule: idx,
742            }) {
743                changed.push(resp_id);
744            }
745        }
746        Ok(changed)
747    }
748
749    fn cmd_update_respond(
750        &mut self,
751        id: NodeId,
752        respond: crate::view::RespondPayload,
753    ) -> Result<Vec<NodeId>, ApplyError> {
754        let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
755        let NodeAddress::Respond {
756            rule_set: rs_idx,
757            rule: rule_idx,
758        } = addr
759        else {
760            return Err(ApplyError::WrongNodeKind {
761                id,
762                reason: "expected a respond id".to_owned(),
763            });
764        };
765
766        let rule = self
767            .config
768            .service
769            .rule_sets
770            .get_mut(rs_idx)
771            .and_then(|rs| rs.rules.get_mut(rule_idx))
772            .ok_or_else(|| ApplyError::InvalidPayload {
773                reason: format!(
774                    "rule at rule_set={}, rule={} not found",
775                    rs_idx, rule_idx
776                ),
777            })?;
778
779        rule.respond = build_respond_from_payload(respond);
780
781        // Re-run status-code derivation so the updated `status` field
782        // has its matching `StatusCode` stored.
783        let rule_set = &self.config.service.rule_sets[rs_idx];
784        let derived = rule_set.rules[rule_idx].compute_derived_fields(rule_set, rule_idx, rs_idx);
785        self.config.service.rule_sets[rs_idx].rules[rule_idx] = derived;
786
787        Ok(vec![id])
788    }
789
790    fn cmd_update_root_setting(
791        &mut self,
792        key: crate::view::RootSettingKey,
793        value: EditValue,
794    ) -> Result<Vec<NodeId>, ApplyError> {
795        use crate::view::RootSettingKey::*;
796
797        match key {
798            ListenerIpAddress => {
799                let s = value_as_string(&value)?;
800                let listener = self.config.listener.get_or_insert_with(Default::default);
801                listener.ip_address = s;
802            }
803            ListenerPort => {
804                let n = value_as_integer(&value)?;
805                if !(0..=u16::MAX as i64).contains(&n) {
806                    return Err(ApplyError::InvalidPayload {
807                        reason: format!("port {} not in 0..=65535", n),
808                    });
809                }
810                let listener = self.config.listener.get_or_insert_with(Default::default);
811                listener.port = n as u16;
812            }
813            ServiceFallbackRespondDir => {
814                let s = value_as_string(&value)?;
815                self.config.service.fallback_respond_dir = s;
816            }
817            ServiceStrategy => {
818                let s = value_as_string(&value)?;
819                // The only recognised strategy value today is
820                // "first_match". Anything else is rejected — if future
821                // strategies are added, extend this match.
822                match s.as_str() {
823                    "first_match" => {
824                        self.config.service.strategy =
825                            Some(apimock_routing::Strategy::FirstMatch);
826                    }
827                    other => {
828                        return Err(ApplyError::InvalidPayload {
829                            reason: format!("unknown strategy: {}", other),
830                        });
831                    }
832                }
833            }
834        }
835
836        // Root is a singleton; its NodeId is always the same.
837        let id = self
838            .ids
839            .id_for(NodeAddress::Root)
840            .expect("root id seeded at load");
841        Ok(vec![id])
842    }
843
844    // --- Shared helpers ----------------------------------------------
845
846    /// After a rule set is removed at `removed_idx`, migrate every ID
847    /// whose address referenced a later rule set to its new index.
848    fn shift_rule_sets_down(&mut self, removed_idx: usize) {
849        // Walk current layout (after removal). For each surviving
850        // rule_set at new index `new_idx`, the *old* index was
851        // `new_idx` if `new_idx < removed_idx` (no shift needed) or
852        // `new_idx + 1` if `new_idx >= removed_idx` (it shifted down).
853        // We rebuild mappings only for the shifted half.
854        let new_rs_count = self.config.service.rule_sets.len();
855
856        // First drop any stale ID entries for the removed index and
857        // for everything whose old address will be replaced.
858        // Collect stale (old) addresses first, then update `self.ids`.
859        let mut stale: Vec<NodeAddress> = Vec::new();
860        stale.push(NodeAddress::RuleSet {
861            rule_set: removed_idx,
862        });
863        // The old index range is [removed_idx, new_rs_count+1).
864        for old_idx in removed_idx..new_rs_count + 1 {
865            stale.push(NodeAddress::RuleSet { rule_set: old_idx });
866            // We don't know the old rule counts any more, so we walk
867            // the id index for matches.
868        }
869
870        // Safer approach: pull all entries whose address's rule_set
871        // field is >= removed_idx (both Rule and Respond and RuleSet),
872        // and rebuild them.
873        let mut to_migrate: Vec<(NodeId, NodeAddress)> = Vec::new();
874        for (&addr, &id) in self.ids.address_to_id.iter() {
875            match addr {
876                NodeAddress::RuleSet { rule_set } if rule_set >= removed_idx => {
877                    to_migrate.push((id, addr));
878                }
879                NodeAddress::Rule { rule_set, .. } if rule_set >= removed_idx => {
880                    to_migrate.push((id, addr));
881                }
882                NodeAddress::Respond { rule_set, .. } if rule_set >= removed_idx => {
883                    to_migrate.push((id, addr));
884                }
885                _ => {}
886            }
887        }
888
889        for (id, addr) in &to_migrate {
890            self.ids.address_to_id.remove(addr);
891            self.ids.id_to_address.remove(id);
892        }
893
894        // Re-insert with shifted addresses, skipping anything that
895        // belonged to the removed rule set.
896        for (id, addr) in to_migrate {
897            let new_addr = match addr {
898                NodeAddress::RuleSet { rule_set } => {
899                    if rule_set == removed_idx {
900                        continue; // removed outright
901                    }
902                    NodeAddress::RuleSet {
903                        rule_set: rule_set - 1,
904                    }
905                }
906                NodeAddress::Rule { rule_set, rule } => {
907                    if rule_set == removed_idx {
908                        continue;
909                    }
910                    NodeAddress::Rule {
911                        rule_set: rule_set - 1,
912                        rule,
913                    }
914                }
915                NodeAddress::Respond { rule_set, rule } => {
916                    if rule_set == removed_idx {
917                        continue;
918                    }
919                    NodeAddress::Respond {
920                        rule_set: rule_set - 1,
921                        rule,
922                    }
923                }
924                other => other,
925            };
926            self.ids.id_to_address.insert(id, new_addr);
927            self.ids.address_to_id.insert(new_addr, id);
928        }
929    }
930
931    /// After a rule is deleted from `rule_set_idx` at position
932    /// `removed_rule_idx`, shift IDs for later rules in the same set.
933    fn shift_rules_down(&mut self, rule_set_idx: usize, removed_rule_idx: usize) {
934        let mut to_migrate: Vec<(NodeId, NodeAddress)> = Vec::new();
935        for (&addr, &id) in self.ids.address_to_id.iter() {
936            match addr {
937                NodeAddress::Rule { rule_set, rule }
938                    if rule_set == rule_set_idx && rule >= removed_rule_idx =>
939                {
940                    to_migrate.push((id, addr));
941                }
942                NodeAddress::Respond { rule_set, rule }
943                    if rule_set == rule_set_idx && rule >= removed_rule_idx =>
944                {
945                    to_migrate.push((id, addr));
946                }
947                _ => {}
948            }
949        }
950
951        for (id, addr) in &to_migrate {
952            self.ids.address_to_id.remove(addr);
953            self.ids.id_to_address.remove(id);
954        }
955
956        for (id, addr) in to_migrate {
957            let new_addr = match addr {
958                NodeAddress::Rule { rule_set, rule } => {
959                    if rule == removed_rule_idx {
960                        continue;
961                    }
962                    NodeAddress::Rule {
963                        rule_set,
964                        rule: rule - 1,
965                    }
966                }
967                NodeAddress::Respond { rule_set, rule } => {
968                    if rule == removed_rule_idx {
969                        continue;
970                    }
971                    NodeAddress::Respond {
972                        rule_set,
973                        rule: rule - 1,
974                    }
975                }
976                other => other,
977            };
978            self.ids.id_to_address.insert(id, new_addr);
979            self.ids.address_to_id.insert(new_addr, id);
980        }
981    }
982
983    /// After a rule in `rule_set_idx` moves from `old_idx` to
984    /// `new_idx`, shuffle the IDs of every rule between those indices.
985    fn reorder_rule_ids(&mut self, rule_set_idx: usize, old_idx: usize, new_idx: usize) {
986        // Grab current mapping for all rules in this rule set.
987        let rule_count = self.config.service.rule_sets[rule_set_idx].rules.len();
988        let mut rule_ids: Vec<Option<NodeId>> = (0..rule_count)
989            .map(|r| {
990                self.ids.id_for(NodeAddress::Rule {
991                    rule_set: rule_set_idx,
992                    rule: r,
993                })
994            })
995            .collect();
996        let mut resp_ids: Vec<Option<NodeId>> = (0..rule_count)
997            .map(|r| {
998                self.ids.id_for(NodeAddress::Respond {
999                    rule_set: rule_set_idx,
1000                    rule: r,
1001                })
1002            })
1003            .collect();
1004
1005        // Before the config move, `rule_ids[old_idx]` held the moving
1006        // rule's old ID. But the config mutation already happened —
1007        // so the id_for lookups above are pre-migration (the ids
1008        // didn't change), they simply don't match the new layout yet.
1009        // We mimic the same move on `rule_ids`:
1010        let moving_r = rule_ids.remove(old_idx);
1011        rule_ids.insert(new_idx, moving_r);
1012        let moving_resp = resp_ids.remove(old_idx);
1013        resp_ids.insert(new_idx, moving_resp);
1014
1015        // Clear old mappings for these addresses and repopulate.
1016        for r in 0..rule_count {
1017            let rule_addr = NodeAddress::Rule {
1018                rule_set: rule_set_idx,
1019                rule: r,
1020            };
1021            let resp_addr = NodeAddress::Respond {
1022                rule_set: rule_set_idx,
1023                rule: r,
1024            };
1025            if let Some(prev_id) = self.ids.address_to_id.remove(&rule_addr) {
1026                self.ids.id_to_address.remove(&prev_id);
1027            }
1028            if let Some(prev_id) = self.ids.address_to_id.remove(&resp_addr) {
1029                self.ids.id_to_address.remove(&prev_id);
1030            }
1031        }
1032        for (r, id_opt) in rule_ids.into_iter().enumerate() {
1033            let addr = NodeAddress::Rule {
1034                rule_set: rule_set_idx,
1035                rule: r,
1036            };
1037            let id = id_opt.unwrap_or_else(NodeId::new);
1038            self.ids.id_to_address.insert(id, addr);
1039            self.ids.address_to_id.insert(addr, id);
1040        }
1041        for (r, id_opt) in resp_ids.into_iter().enumerate() {
1042            let addr = NodeAddress::Respond {
1043                rule_set: rule_set_idx,
1044                rule: r,
1045            };
1046            let id = id_opt.unwrap_or_else(NodeId::new);
1047            self.ids.id_to_address.insert(id, addr);
1048            self.ids.address_to_id.insert(addr, id);
1049        }
1050    }
1051
1052    fn config_relative_dir(&self) -> Result<String, ConfigError> {
1053        self.config.current_dir_to_parent_dir_relative_path()
1054    }
1055
1056    /// Walk every node, asking it for its validation state, and return
1057    /// the flat list of issues. Used at apply-time and on demand from
1058    /// `validate()`.
1059    fn collect_diagnostics(&self) -> Vec<Diagnostic> {
1060        let mut out: Vec<Diagnostic> = Vec::new();
1061        for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
1062            for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
1063                let nv = respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx);
1064                if nv.ok {
1065                    continue;
1066                }
1067                let resp_id = self.ids.id_for(NodeAddress::Respond {
1068                    rule_set: rs_idx,
1069                    rule: rule_idx,
1070                });
1071                for issue in nv.issues {
1072                    out.push(Diagnostic {
1073                        node_id: resp_id,
1074                        file: Some(PathBuf::from(rule_set.file_path.as_str())),
1075                        severity: issue.severity,
1076                        message: issue.message,
1077                    });
1078                }
1079            }
1080        }
1081
1082        // Root-level check: fallback_respond_dir must exist.
1083        if !Path::new(self.config.service.fallback_respond_dir.as_str()).exists() {
1084            out.push(Diagnostic {
1085                node_id: self.ids.id_for(NodeAddress::FallbackRespondDir),
1086                file: Some(self.root_path.clone()),
1087                severity: Severity::Error,
1088                message: format!(
1089                    "fallback_respond_dir does not exist: {}",
1090                    self.config.service.fallback_respond_dir
1091                ),
1092            });
1093        }
1094
1095        out
1096    }
1097
1098    // --- Public API ----
1099
1100    /// Validate the workspace and return a GUI-ready report.
1101    ///
1102    /// Uses the same per-node checks `snapshot()` does so the numbers
1103    /// line up: a node rendered with a red underline in the snapshot
1104    /// will appear in `report.diagnostics` with the same message.
1105    pub fn validate(&self) -> ValidationReport {
1106        let diagnostics = self.collect_diagnostics();
1107        let is_valid = !diagnostics
1108            .iter()
1109            .any(|d| matches!(d.severity, Severity::Error));
1110        ValidationReport {
1111            diagnostics,
1112            is_valid,
1113        }
1114    }
1115
1116    /// Save the workspace back to disk.
1117    ///
1118    /// # Algorithm
1119    ///
1120    /// 1. Render each editable file (root + each rule set) to TOML text.
1121    /// 2. Compare against `baseline_files`. Files whose rendered output
1122    ///    is byte-identical to the baseline are skipped — the user's
1123    ///    formatting / comments survive untouched in that case.
1124    /// 3. For files that *do* differ, write atomically via
1125    ///    `tempfile::NamedTempFile::persist` (same-directory rename(2)
1126    ///    on POSIX, `MoveFileExW` on Windows). On any single-file
1127    ///    failure, the partial state is whatever rename(2)s have
1128    ///    already succeeded — see the type-level docstring on
1129    ///    `SaveError` for the rationale.
1130    /// 4. After all writes succeed, refresh `baseline_files` so a
1131    ///    subsequent save() won't re-write the same files needlessly.
1132    /// 5. Compute `DiffItem`s by node, comparing the in-memory state
1133    ///    to the load-time baseline (parsed; not text-diff).
1134    /// 6. Compute `requires_reload` / `requires_restart` from the set
1135    ///    of changed files: changes to `[listener]` need a restart,
1136    ///    everything else just a reload.
1137    ///
1138    /// # The "save loses comments" diagnostic
1139    ///
1140    /// Per the GUI spec §6 / §11, save is allowed to lose comments and
1141    /// formatting. We surface this as an `Info`-severity diagnostic
1142    /// the first time a save would actually overwrite a file that has
1143    /// non-trivial formatting (any file whose TOML round-trip is not
1144    /// byte-identical, which is essentially every hand-edited file).
1145    /// A polished GUI shows it once per session.
1146    pub fn save(&mut self) -> Result<SaveResult, SaveError> {
1147        // --- Render every file's new content -------------------------
1148        let new_root_toml = crate::toml_writer::render_apimock_toml(&self.config);
1149
1150        let mut rule_set_renders: Vec<(PathBuf, String)> = Vec::new();
1151        for rule_set in self.config.service.rule_sets.iter() {
1152            let path = PathBuf::from(rule_set.file_path.as_str());
1153            let text = crate::toml_writer::render_rule_set_toml(rule_set);
1154            rule_set_renders.push((path, text));
1155        }
1156
1157        // --- Compute changed-file set --------------------------------
1158        let mut to_write: Vec<(PathBuf, String)> = Vec::new();
1159
1160        let baseline_root = self.baseline_files.get(&self.root_path);
1161        if baseline_root.map(String::as_str) != Some(new_root_toml.as_str()) {
1162            to_write.push((self.root_path.clone(), new_root_toml.clone()));
1163        }
1164        for (path, text) in rule_set_renders.iter() {
1165            let baseline = self.baseline_files.get(path);
1166            if baseline.map(String::as_str) != Some(text.as_str()) {
1167                to_write.push((path.clone(), text.clone()));
1168            }
1169        }
1170
1171        // --- Atomic write via tempfile::persist ----------------------
1172        let mut written: Vec<PathBuf> = Vec::with_capacity(to_write.len());
1173        for (path, text) in &to_write {
1174            atomic_write(path, text)?;
1175            written.push(path.clone());
1176        }
1177
1178        // --- Build diff_summary BEFORE updating baseline ------------
1179        // The diff is "what did this save flush to disk", computed
1180        // against the *previous* baseline. Once we refresh the
1181        // baseline below, every node would compare equal again.
1182        let diff_summary = self.compute_diff_summary();
1183
1184        // --- Refresh baseline ---------------------------------------
1185        for (path, text) in to_write.into_iter() {
1186            self.baseline_files.insert(path, text);
1187        }
1188
1189        // --- Reload hint --------------------------------------------
1190        // If the root file (which holds [listener]) was rewritten we
1191        // conservatively flag a restart. Otherwise rule-set-only changes
1192        // are a plain reload.
1193        let listener_changed = written.contains(&self.root_path);
1194        let requires_reload = listener_changed || !written.is_empty();
1195
1196        Ok(SaveResult {
1197            changed_files: written,
1198            diff_summary,
1199            requires_reload,
1200        })
1201    }
1202
1203    /// Compute the diff summary for the most recent save: one entry
1204    /// per node whose rendered representation has changed since load.
1205    ///
1206    /// # Why this isn't a textual diff
1207    ///
1208    /// A line-by-line text diff would surface noise from formatting
1209    /// (key reordering, comment loss). The GUI wants to know which
1210    /// *logical* nodes the user changed — so we walk the node-address
1211    /// space, compare the in-memory state to a re-parsed snapshot of
1212    /// the baseline, and emit `DiffItem`s keyed by `NodeId`.
1213    ///
1214    /// # Granularity
1215    ///
1216    /// 5.3.0 emits diffs at three granularities, in this order:
1217    ///
1218    /// 1. **Per-rule** `Updated` / `Added` / `Removed` for changes
1219    ///    inside a rule set whose top-level structure (rule count,
1220    ///    prefixes) is otherwise stable.
1221    /// 2. **Per-rule-set** `Added` for newly-introduced rule sets the
1222    ///    baseline didn't have at all.
1223    /// 3. **Root file** `Updated` when listener / log / service-level
1224    ///    fields changed.
1225    fn compute_diff_summary(&self) -> Vec<crate::view::DiffItem> {
1226        use crate::view::{DiffItem, DiffKind};
1227
1228        let mut out = Vec::new();
1229
1230        // Per-rule diffs for rule sets that exist in baseline and current.
1231        for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
1232            let path = PathBuf::from(rule_set.file_path.as_str());
1233            let rendered = crate::toml_writer::render_rule_set_toml(rule_set);
1234            let baseline_text = self.baseline_files.get(&path);
1235            let baseline_matches = baseline_text
1236                .map(|s| s.as_str() == rendered.as_str())
1237                .unwrap_or(false);
1238            if baseline_matches {
1239                continue;
1240            }
1241
1242            if let Some(baseline) = baseline_text {
1243                // Both baseline and current exist — try a per-rule diff.
1244                self.append_per_rule_diff(rs_idx, rule_set, baseline, &mut out);
1245            } else {
1246                // Newly added rule set (no baseline file). Surface as
1247                // a single rule-set-level Added.
1248                if let Some(rs_id) = self.ids.id_for(NodeAddress::RuleSet { rule_set: rs_idx }) {
1249                    out.push(DiffItem {
1250                        kind: DiffKind::Added,
1251                        target: rs_id,
1252                        summary: format!(
1253                            "rule set #{} ({}): rules={}",
1254                            rs_idx + 1,
1255                            file_basename(&path),
1256                            rule_set.rules.len(),
1257                        ),
1258                    });
1259                }
1260            }
1261        }
1262
1263        // Did the root file diverge?
1264        let root_rendered = crate::toml_writer::render_apimock_toml(&self.config);
1265        let root_baseline_matches = self
1266            .baseline_files
1267            .get(&self.root_path)
1268            .map(|s| s.as_str() == root_rendered.as_str())
1269            .unwrap_or(false);
1270        if !root_baseline_matches {
1271            if let Some(root_id) = self.ids.id_for(NodeAddress::Root) {
1272                out.push(DiffItem {
1273                    kind: DiffKind::Updated,
1274                    target: root_id,
1275                    summary: format!(
1276                        "{}: listener / log / service",
1277                        file_basename(&self.root_path)
1278                    ),
1279                });
1280            }
1281        }
1282
1283        out
1284    }
1285
1286    /// Walk the rules in `rule_set` against the baseline TOML's `rules`
1287    /// array, emitting one `DiffItem` per rule that changed.
1288    ///
1289    /// # Pairing strategy
1290    ///
1291    /// Matching by *index* (rule[0] vs baseline_rule[0], rule[1] vs
1292    /// baseline_rule[1], ...). After an insert / delete in the middle
1293    /// of a list, this would over-report — every rule past the
1294    /// insertion point would look "updated". A stage-5 candidate is to
1295    /// match by NodeId so insertions don't fan out. For 5.3.0,
1296    /// index-pairing is the simplest correct choice; the over-report
1297    /// is observably accurate (all those rules' on-disk positions
1298    /// *did* change) just not minimal.
1299    fn append_per_rule_diff(
1300        &self,
1301        rs_idx: usize,
1302        rule_set: &apimock_routing::RuleSet,
1303        baseline_text: &str,
1304        out: &mut Vec<crate::view::DiffItem>,
1305    ) {
1306        use crate::view::{DiffItem, DiffKind};
1307        use toml::Value;
1308
1309        // Parse baseline back to a TOML value to walk its rules array.
1310        let baseline_value: Value = match toml::from_str(baseline_text) {
1311            Ok(v) => v,
1312            Err(_) => return, // baseline malformed; skip per-rule detail
1313        };
1314        let baseline_rules: &[Value] = match baseline_value
1315            .get("rules")
1316            .and_then(|v| v.as_array())
1317        {
1318            Some(arr) => arr.as_slice(),
1319            None => &[],
1320        };
1321
1322        let cur_len = rule_set.rules.len();
1323        let base_len = baseline_rules.len();
1324        let common = cur_len.min(base_len);
1325
1326        // Compare overlapping rules.
1327        for rule_idx in 0..common {
1328            let cur_rendered = rule_to_string(&rule_set.rules[rule_idx]);
1329            let base_rendered = toml::to_string_pretty(&baseline_rules[rule_idx])
1330                .unwrap_or_default();
1331            if cur_rendered == base_rendered {
1332                continue;
1333            }
1334            let target = self
1335                .ids
1336                .id_for(NodeAddress::Rule {
1337                    rule_set: rs_idx,
1338                    rule: rule_idx,
1339                })
1340                .unwrap_or_else(NodeId::new);
1341            out.push(DiffItem {
1342                kind: DiffKind::Updated,
1343                target,
1344                summary: format!(
1345                    "rule #{} in rule set #{}",
1346                    rule_idx + 1,
1347                    rs_idx + 1
1348                ),
1349            });
1350        }
1351
1352        // Rules added in the current model that weren't in baseline.
1353        for rule_idx in common..cur_len {
1354            let target = self
1355                .ids
1356                .id_for(NodeAddress::Rule {
1357                    rule_set: rs_idx,
1358                    rule: rule_idx,
1359                })
1360                .unwrap_or_else(NodeId::new);
1361            out.push(DiffItem {
1362                kind: DiffKind::Added,
1363                target,
1364                summary: format!(
1365                    "added rule #{} in rule set #{}",
1366                    rule_idx + 1,
1367                    rs_idx + 1
1368                ),
1369            });
1370        }
1371
1372        // Rules removed: present in baseline, not in current. We
1373        // can't attribute these to a NodeId (the rule's id was deleted
1374        // from the index when DeleteRule ran), so we emit a fresh id
1375        // and a clear summary; the GUI surfaces these as removals.
1376        for rule_idx in common..base_len {
1377            out.push(DiffItem {
1378                kind: DiffKind::Removed,
1379                target: NodeId::new(),
1380                summary: format!(
1381                    "removed rule #{} from rule set #{}",
1382                    rule_idx + 1,
1383                    rs_idx + 1
1384                ),
1385            });
1386        }
1387    }
1388
1389    /// True when at least one editable file's rendered output differs
1390    /// from its load-time baseline.
1391    ///
1392    /// # Use case
1393    ///
1394    /// A GUI's "unsaved changes" indicator polls this. Cheap relative
1395    /// to a full save (no file I/O, just renders + string compares).
1396    pub fn has_unsaved_changes(&self) -> bool {
1397        let root_text = crate::toml_writer::render_apimock_toml(&self.config);
1398        if self
1399            .baseline_files
1400            .get(&self.root_path)
1401            .map(|s| s.as_str())
1402            != Some(root_text.as_str())
1403        {
1404            return true;
1405        }
1406        for rule_set in self.config.service.rule_sets.iter() {
1407            let path = PathBuf::from(rule_set.file_path.as_str());
1408            let text = crate::toml_writer::render_rule_set_toml(rule_set);
1409            if self
1410                .baseline_files
1411                .get(&path)
1412                .map(|s| s.as_str())
1413                != Some(text.as_str())
1414            {
1415                return true;
1416            }
1417        }
1418        false
1419    }
1420
1421    /// Root config file as a `ConfigFileView`, if it can be rendered.
1422    fn root_file_nodes(&self) -> Option<ConfigFileView> {
1423        let mut nodes = Vec::new();
1424
1425        if let Some(root_id) = self.ids.id_for(NodeAddress::Root) {
1426            nodes.push(ConfigNodeView {
1427                id: root_id,
1428                source_file: self.root_path.clone(),
1429                toml_path: String::new(),
1430                display_name: "apimock.toml".to_owned(),
1431                kind: NodeKind::RootSetting,
1432                validation: NodeValidation::ok(),
1433            });
1434        }
1435
1436        if let Some(fb_id) = self.ids.id_for(NodeAddress::FallbackRespondDir) {
1437            nodes.push(ConfigNodeView {
1438                id: fb_id,
1439                source_file: self.root_path.clone(),
1440                toml_path: "service.fallback_respond_dir".to_owned(),
1441                display_name: self.config.service.fallback_respond_dir.clone(),
1442                kind: NodeKind::FileNode,
1443                validation: NodeValidation::ok(),
1444            });
1445        }
1446
1447        Some(ConfigFileView {
1448            path: self.root_path.clone(),
1449            display_name: file_basename(&self.root_path),
1450            kind: ConfigFileKind::Root,
1451            nodes,
1452        })
1453    }
1454
1455    fn rule_set_file_view(&self, rs_idx: usize, rule_set: &RuleSet) -> ConfigFileView {
1456        let file_path = PathBuf::from(rule_set.file_path.as_str());
1457        let mut nodes: Vec<ConfigNodeView> = Vec::new();
1458
1459        // Rule-set itself.
1460        if let Some(rs_id) = self
1461            .ids
1462            .id_for(NodeAddress::RuleSet { rule_set: rs_idx })
1463        {
1464            nodes.push(ConfigNodeView {
1465                id: rs_id,
1466                source_file: file_path.clone(),
1467                toml_path: String::new(),
1468                display_name: file_basename(&file_path),
1469                kind: NodeKind::RuleSet,
1470                validation: NodeValidation::ok(),
1471            });
1472        }
1473
1474        // Rules inside.
1475        for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
1476            if let Some(rule_id) = self.ids.id_for(NodeAddress::Rule {
1477                rule_set: rs_idx,
1478                rule: rule_idx,
1479            }) {
1480                let url_path_label = rule
1481                    .when
1482                    .request
1483                    .url_path
1484                    .as_ref()
1485                    .map(|u| u.value.as_str())
1486                    .unwrap_or_default();
1487                let display = if url_path_label.is_empty() {
1488                    format!("Rule #{}", rule_idx + 1)
1489                } else {
1490                    url_path_label.to_owned()
1491                };
1492                nodes.push(ConfigNodeView {
1493                    id: rule_id,
1494                    source_file: file_path.clone(),
1495                    toml_path: format!("rules[{}]", rule_idx),
1496                    display_name: display,
1497                    kind: NodeKind::Rule,
1498                    validation: NodeValidation::ok(),
1499                });
1500            }
1501
1502            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
1503                rule_set: rs_idx,
1504                rule: rule_idx,
1505            }) {
1506                nodes.push(ConfigNodeView {
1507                    id: resp_id,
1508                    source_file: file_path.clone(),
1509                    toml_path: format!("rules[{}].respond", rule_idx),
1510                    display_name: summarise_respond(&rule.respond),
1511                    kind: NodeKind::Respond,
1512                    validation: respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx),
1513                });
1514            }
1515        }
1516
1517        ConfigFileView {
1518            path: file_path.clone(),
1519            display_name: file_basename(&file_path),
1520            kind: ConfigFileKind::RuleSet,
1521            nodes,
1522        }
1523    }
1524
1525    fn resolve_relative(&self, rel: &str) -> PathBuf {
1526        match self.config.current_dir_to_parent_dir_relative_path() {
1527            Ok(dir) => Path::new(&dir).join(rel),
1528            Err(_) => PathBuf::from(rel),
1529        }
1530    }
1531
1532    /// Access the underlying `Config`. Intended for embedders that
1533    /// need to build a running `Server` from the same workspace. Edit
1534    /// via `apply()` instead of touching `Config` directly — changes
1535    /// made through this reference are invisible to the ID index.
1536    pub fn config(&self) -> &Config {
1537        &self.config
1538    }
1539
1540    /// Access the root path. Primarily for diagnostics.
1541    pub fn root_path(&self) -> &Path {
1542        &self.root_path
1543    }
1544
1545    /// Expand a directory in the file tree on demand.
1546    ///
1547    /// # When the GUI calls this
1548    ///
1549    /// `Workspace::snapshot()` returns a `FileTreeView` populated with
1550    /// just the top-level entries of the fallback respond dir. Each
1551    /// directory entry carries `children: Some(Vec::new())` to flag it
1552    /// as expandable. When a user clicks to expand one of those nodes,
1553    /// the GUI calls `list_directory(&entry.path)` and gets back the
1554    /// next depth's entries (still not recursed past that depth — the
1555    /// same lazy contract holds).
1556    ///
1557    /// # Why path-based and not NodeId-based
1558    ///
1559    /// File-tree entries don't carry NodeIds (see `FileNodeView`). The
1560    /// reason is lifecycle: the editable node space (rules, rule sets,
1561    /// respond blocks) is small, stable, and survives `apply()` calls
1562    /// — perfect for UUID-keyed state. The file tree is large,
1563    /// transient, and reflects the filesystem rather than the model;
1564    /// keying it by path keeps the API simple and avoids mixing two
1565    /// kinds of identity.
1566    pub fn list_directory(&self, path: &Path) -> Vec<apimock_routing::view::FileNodeView> {
1567        apimock_routing::view::build::list_directory(path)
1568    }
1569}
1570
1571/// Collapse a `Respond` into a one-line display label.
1572fn summarise_respond(respond: &apimock_routing::Respond) -> String {
1573    if let Some(p) = respond.file_path.as_ref() {
1574        return format!("file: {}", p);
1575    }
1576    if let Some(t) = respond.text.as_ref() {
1577        const LIMIT: usize = 40;
1578        if t.chars().count() > LIMIT {
1579            let truncated: String = t.chars().take(LIMIT).collect();
1580            return format!("text: {}…", truncated);
1581        }
1582        return format!("text: {}", t);
1583    }
1584    if let Some(s) = respond.status.as_ref() {
1585        return format!("status: {}", s);
1586    }
1587    "(empty)".to_owned()
1588}
1589
1590fn respond_node_validation(
1591    respond: &apimock_routing::Respond,
1592    rule_set: &RuleSet,
1593    rule_idx: usize,
1594    rs_idx: usize,
1595) -> NodeValidation {
1596    // `Respond::validate` logs errors but returns a bool. For 5.1
1597    // per-node validation we want structured messages — so we replicate
1598    // the specific checks here rather than piping through the logger.
1599    let mut issues: Vec<ValidationIssue> = Vec::new();
1600
1601    let any = respond.file_path.is_some() || respond.text.is_some() || respond.status.is_some();
1602    if !any {
1603        issues.push(ValidationIssue {
1604            severity: Severity::Error,
1605            message: "response requires at least one of file_path, text, or status".to_owned(),
1606        });
1607    }
1608    if respond.file_path.is_some() && respond.text.is_some() {
1609        issues.push(ValidationIssue {
1610            severity: Severity::Error,
1611            message: "file_path and text cannot both be set".to_owned(),
1612        });
1613    }
1614    if respond.file_path.is_some() && respond.status.is_some() {
1615        issues.push(ValidationIssue {
1616            severity: Severity::Error,
1617            message: "status cannot be combined with file_path (only with text)".to_owned(),
1618        });
1619    }
1620
1621    // file-existence validation: this is the same behaviour the old
1622    // `Respond::validate(dir_prefix, …)` performed. We don't call it
1623    // directly because it writes to `log::error!`, which would flood
1624    // the console during every GUI snapshot.
1625    if let Some(file_path) = respond.file_path.as_ref() {
1626        let dir_prefix = rule_set.dir_prefix();
1627        let p = Path::new(dir_prefix.as_str()).join(file_path);
1628        if !p.exists() {
1629            issues.push(ValidationIssue {
1630                severity: Severity::Error,
1631                message: format!(
1632                    "file not found: {} (rule #{} in rule set #{})",
1633                    p.to_string_lossy(),
1634                    rule_idx + 1,
1635                    rs_idx + 1,
1636                ),
1637            });
1638        }
1639    }
1640
1641    NodeValidation {
1642        ok: issues.is_empty(),
1643        issues,
1644    }
1645}
1646
1647fn file_basename(path: &Path) -> String {
1648    path.file_name()
1649        .map(|n| n.to_string_lossy().into_owned())
1650        .unwrap_or_else(|| path.to_string_lossy().into_owned())
1651}
1652
1653/// Render a single rule to canonical TOML text. Used by per-rule diff
1654/// to compare baseline rules to current rules in a format-agnostic
1655/// way (the same canonicalisation `toml_writer` applies to whole
1656/// files).
1657fn rule_to_string(rule: &apimock_routing::Rule) -> String {
1658    let table = crate::toml_writer::rule_table(rule);
1659    toml::to_string_pretty(&toml::Value::Table(table)).unwrap_or_default()
1660}
1661
1662/// Write `text` to `path` atomically.
1663///
1664/// # Why a tempfile + persist instead of a direct write
1665///
1666/// `std::fs::write` is two syscalls (truncate + write) with a window
1667/// between them where a concurrent reader can see an empty file. The
1668/// running apimock server reads its own config files when (eventually)
1669/// it supports reload; if it picks a moment in the middle of
1670/// `std::fs::write`, it can fail to parse a half-written TOML.
1671///
1672/// `tempfile::NamedTempFile::persist` writes to `<dir>/.tmpXXXX`,
1673/// `fsync`s, then `rename(2)`s onto the destination — a single
1674/// directory-entry update that the kernel guarantees is atomic. On
1675/// Windows, `tempfile` translates this into `MoveFileExW` with the
1676/// replace-existing flag for the same effect.
1677///
1678/// # Error mapping
1679///
1680/// `tempfile`'s persist returns a `PersistError` that wraps both the
1681/// `NamedTempFile` and the underlying `io::Error`. We unwrap the
1682/// `io::Error` and surface it as `SaveError::Write`. The temp file
1683/// is dropped automatically (and removed) when the persist error
1684/// returns.
1685fn atomic_write(path: &Path, text: &str) -> Result<(), SaveError> {
1686    let parent = path
1687        .parent()
1688        .filter(|p| !p.as_os_str().is_empty())
1689        .map(Path::to_path_buf)
1690        .unwrap_or_else(|| PathBuf::from("."));
1691
1692    let mut tmp =
1693        tempfile::NamedTempFile::new_in(&parent).map_err(|e| SaveError::Write {
1694            path: path.to_path_buf(),
1695            source: e,
1696        })?;
1697
1698    use std::io::Write;
1699    tmp.write_all(text.as_bytes())
1700        .map_err(|e| SaveError::Write {
1701            path: path.to_path_buf(),
1702            source: e,
1703        })?;
1704    tmp.flush().map_err(|e| SaveError::Write {
1705        path: path.to_path_buf(),
1706        source: e,
1707    })?;
1708
1709    tmp.persist(path).map_err(|persist_err| SaveError::Write {
1710        path: path.to_path_buf(),
1711        source: persist_err.error,
1712    })?;
1713    Ok(())
1714}
1715
1716// --- Payload → model helpers used by the apply layer --------------
1717
1718fn build_rule_from_payload(
1719    payload: crate::view::RulePayload,
1720    rule_set: &apimock_routing::RuleSet,
1721    rs_idx: usize,
1722) -> Result<apimock_routing::Rule, ApplyError> {
1723    use apimock_routing::rule_set::rule::Rule;
1724    use apimock_routing::rule_set::rule::when::When;
1725    use apimock_routing::rule_set::rule::when::request::{
1726        Request, http_method::HttpMethod, url_path::UrlPathConfig,
1727    };
1728
1729    // Build the Request shape from the simple payload. We use the
1730    // simple UrlPath variant (Simple(String)) because the payload's
1731    // url_path is a plain string; the richer variants (op, etc.) are
1732    // out of scope for 5.1 — a GUI can round-trip them once Step-5
1733    // exposes richer form controls.
1734    let url_path_config = payload.url_path.as_ref().map(|s| UrlPathConfig::Simple(s.clone()));
1735
1736    let http_method = match payload.method.as_deref() {
1737        Some("GET") | Some("get") => Some(HttpMethod::Get),
1738        Some("POST") | Some("post") => Some(HttpMethod::Post),
1739        Some("PUT") | Some("put") => Some(HttpMethod::Put),
1740        Some("DELETE") | Some("delete") => Some(HttpMethod::Delete),
1741        Some(other) => {
1742            return Err(ApplyError::InvalidPayload {
1743                reason: format!(
1744                    "unsupported HTTP method `{}` — supported: GET, POST, PUT, DELETE",
1745                    other
1746                ),
1747            });
1748        }
1749        None => None,
1750    };
1751
1752    let request = Request {
1753        url_path_config,
1754        url_path: None, // derived below
1755        http_method,
1756        headers: None,
1757        body: None,
1758    };
1759
1760    let rule = Rule {
1761        when: When { request },
1762        respond: build_respond_from_payload(payload.respond),
1763    };
1764
1765    // compute_derived_fields normalises the URL path with the rule
1766    // set's prefix and validates the status code. Running it here means
1767    // the freshly-created rule is ready for matching without a second
1768    // pass.
1769    //
1770    // `rule_idx` at this point is whatever position the rule will
1771    // occupy after being pushed — use `rule_set.rules.len()` because
1772    // the push happens immediately after.
1773    Ok(rule.compute_derived_fields(rule_set, rule_set.rules.len(), rs_idx))
1774}
1775
1776fn build_respond_from_payload(payload: crate::view::RespondPayload) -> apimock_routing::Respond {
1777    apimock_routing::Respond {
1778        file_path: payload.file_path,
1779        csv_records_key: None,
1780        text: payload.text,
1781        status: payload.status,
1782        status_code: None, // derived later
1783        headers: None,
1784        delay_response_milliseconds: payload.delay_milliseconds,
1785    }
1786}
1787
1788fn value_as_string(value: &EditValue) -> Result<String, ApplyError> {
1789    match value {
1790        EditValue::String(s) => Ok(s.clone()),
1791        EditValue::Enum(s) => Ok(s.clone()),
1792        other => Err(ApplyError::InvalidPayload {
1793            reason: format!("expected a string, got {:?}", other),
1794        }),
1795    }
1796}
1797
1798fn value_as_integer(value: &EditValue) -> Result<i64, ApplyError> {
1799    match value {
1800        EditValue::Integer(n) => Ok(*n),
1801        other => Err(ApplyError::InvalidPayload {
1802            reason: format!("expected an integer, got {:?}", other),
1803        }),
1804    }
1805}
1806
1807/// Wrap a ConfigError produced inside an apply command as an
1808/// `ApplyError::InvalidPayload`. Apply uses anyhow-ish flattening
1809/// because the caller doesn't care whether the root cause was a
1810/// read-fail or a parse-fail — they all surface as "edit couldn't
1811/// be applied" from the GUI's point of view.
1812fn internal_path_err(err: ConfigError) -> ApplyError {
1813    ApplyError::InvalidPayload {
1814        reason: format!("internal path resolution failed: {}", err),
1815    }
1816}
1817
1818fn resolve_root(root: &Path) -> Result<PathBuf, WorkspaceError> {
1819    if root.is_file() {
1820        return Ok(root.to_path_buf());
1821    }
1822    if root.is_dir() {
1823        let candidate = root.join("apimock.toml");
1824        if candidate.is_file() {
1825            return Ok(candidate);
1826        }
1827        return Err(WorkspaceError::InvalidRoot {
1828            path: root.to_path_buf(),
1829            reason: "directory does not contain apimock.toml".to_owned(),
1830        });
1831    }
1832    Err(WorkspaceError::InvalidRoot {
1833        path: root.to_path_buf(),
1834        reason: "path does not exist".to_owned(),
1835    })
1836}
1837
1838// Convert a raw `RoutingError` sneaked into the load path; normally
1839// `ConfigError` wraps it, but the explicit conversion keeps the
1840// apply-layer clean when it needs to materialise one.
1841#[allow(dead_code)]
1842fn routing_to_config(err: RoutingError) -> ConfigError {
1843    ConfigError::from(err)
1844}
1845
1846#[cfg(test)]
1847mod tests;