Skip to main content

apimock_config/workspace/
address.rs

1//! Natural-key addressing for editable nodes (RFC 057).
2//!
3//! # Why this exists
4//!
5//! `NodeId` is a fresh UUID per `load()` — stable within one `Workspace`
6//! instance, meaningless across a process boundary. `apimock set` is a
7//! new process per invocation, so it cannot address anything by
8//! `NodeId`: an ID printed by one invocation is unusable by the next.
9//!
10//! `NodeAddress` (`id_index.rs`) already models a natural-key-shaped
11//! position — `rule_set: usize` / `rule: usize` — but it is
12//! `pub(crate)`, on purpose (see that module's doc and RFC 057's
13//! handoff § 2 Unresolved 3): it is positional rather than natural (an
14//! index into `service.rule_sets`, not a path), and it carries variants
15//! `set` doesn't expose (`BodyCondition`, `Middleware`,
16//! `FallbackRespondDir`). Publishing it would freeze an internal shape
17//! onto a contract that must stay stable.
18//!
19//! So this module is deliberately small: a **resolution function** —
20//! turn an already-path-resolved rule-set index plus a 0-based rule
21//! index into the `NodeId` `apply()` needs — and an **address
22//! renderer** — turn a `NodeId` back into a natural-key string for a
23//! preview or diff summary, so neither ever has to serialise a
24//! `NodeId`. The path half of `set`'s (path, index) address is resolved
25//! by the caller against `Workspace::config().service.rule_sets`,
26//! which is already public; nothing here duplicates that.
27
28use crate::view::{DiffItem, NodeId};
29
30use super::Workspace;
31use super::id_index::NodeAddress;
32
33impl Workspace {
34    /// The `NodeId` of the rule set at this index, if one exists.
35    pub fn rule_set_id_at(&self, rule_set: usize) -> Option<NodeId> {
36        self.ids.id_for(NodeAddress::RuleSet { rule_set })
37    }
38
39    /// The `NodeId` of the rule at `(rule_set, rule)`, if one exists.
40    /// Both indices are 0-based, matching `get`'s JSON `matched` block
41    /// (RFC 057's handoff § 1.3 — `set` takes the same base as the
42    /// machine-readable contract, not the 1-based text display).
43    pub fn rule_id_at(&self, rule_set: usize, rule: usize) -> Option<NodeId> {
44        self.ids.id_for(NodeAddress::Rule { rule_set, rule })
45    }
46
47    /// The `NodeId` of the `respond` block at `(rule_set, rule)`, if one
48    /// exists. Distinct from `rule_id_at`'s `NodeId` — `respond` is its
49    /// own addressable node (`EditCommand::UpdateRespond` targets it,
50    /// not the rule).
51    pub fn respond_id_at(&self, rule_set: usize, rule: usize) -> Option<NodeId> {
52        self.ids.id_for(NodeAddress::Respond { rule_set, rule })
53    }
54
55    /// Render a `NodeId` back to a human-readable natural-key
56    /// description — never the UUID itself. Used to label `--dry-run`
57    /// previews and diff-summary rows without the single highest-risk
58    /// mistake this RFC's handoff calls out: serialising `DiffItem`
59    /// (whose `target: NodeId` is `#[serde(transparent)]` over a
60    /// `Uuid`) directly into `set`'s JSON output.
61    ///
62    /// A plain `String` rather than a typed enum deliberately: RFC 057
63    /// § 2 Unresolved 3 asks for a renderer, not a second address type
64    /// to keep stable — a string has no shape to freeze. Returns `None`
65    /// only for a `NodeId` this workspace's index has never seen (a
66    /// stale ID from a different `load()`), which `set`'s one-load,
67    /// one-invocation lifecycle should never actually produce.
68    pub fn describe(&self, id: NodeId) -> Option<String> {
69        let rule_set_label = |rule_set: usize| -> String {
70            self.config
71                .service
72                .rule_sets
73                .get(rule_set)
74                .map(|rs| rs.file_path.clone())
75                .unwrap_or_else(|| format!("rule set #{rule_set}"))
76        };
77        let addr = self.ids.lookup(id)?;
78        Some(match addr {
79            NodeAddress::Root => "root config".to_owned(),
80            NodeAddress::RuleSet { rule_set } => {
81                format!("rule set `{}`", rule_set_label(rule_set))
82            }
83            NodeAddress::Rule { rule_set, rule } => {
84                format!("rule set `{}`, rule #{rule}", rule_set_label(rule_set))
85            }
86            NodeAddress::Respond { rule_set, rule } => {
87                format!(
88                    "rule set `{}`, rule #{rule} respond",
89                    rule_set_label(rule_set)
90                )
91            }
92            NodeAddress::Middleware { middleware } => format!("middleware #{middleware}"),
93            NodeAddress::FallbackRespondDir => "fallback_respond_dir".to_owned(),
94            NodeAddress::HeaderCondition {
95                rule_set,
96                rule,
97                header_name,
98            } => format!(
99                "rule set `{}`, rule #{rule}, header `{header_name}`",
100                rule_set_label(rule_set)
101            ),
102            NodeAddress::BodyCondition {
103                rule_set,
104                rule,
105                path,
106            } => format!(
107                "rule set `{}`, rule #{rule}, body `{path}`",
108                rule_set_label(rule_set)
109            ),
110        })
111    }
112
113    /// What a `save()` right now would write, without writing it.
114    /// Thin public wrapper over the existing `pub(super)`
115    /// `compute_diff_summary` — RFC 057's `--dry-run` needs exactly
116    /// this and nothing `save()`'s other side effects (the atomic
117    /// writes, the baseline refresh).
118    pub fn preview_changes(&self) -> Vec<DiffItem> {
119        self.compute_diff_summary()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use std::path::PathBuf;
126
127    use crate::{
128        Workspace,
129        view::{EditCommand, RespondPayload, RulePayload},
130    };
131
132    /// A minimal on-disk workspace, local to this module — `address.rs`
133    /// lives outside `workspace/tests/`, so `workspace/tests/common.rs`'s
134    /// `pub(super)` fixtures aren't visible here (they're scoped to
135    /// `workspace::tests`, not `workspace::address`).
136    fn workspace_with_two_rules() -> (tempfile::TempDir, PathBuf) {
137        let dir = tempfile::tempdir().expect("tempdir");
138        let rs_toml = concat!(
139            "[[rules]]\n",
140            "when.request.url_path = \"/a\"\n",
141            "respond = { text = \"a\" }\n",
142            "\n",
143            "[[rules]]\n",
144            "when.request.url_path = \"/b\"\n",
145            "respond = { text = \"b\" }\n",
146        );
147        let rs_path = dir.path().join("apimock-rule-set.toml");
148        std::fs::write(&rs_path, rs_toml).unwrap();
149        let root_toml =
150            "[service]\nrule_sets = [\"apimock-rule-set.toml\"]\nfallback_respond_dir = \".\"\n";
151        let root_path = dir.path().join("apimock.toml");
152        std::fs::write(&root_path, root_toml).unwrap();
153        (dir, root_path)
154    }
155
156    #[test]
157    fn rule_set_id_at_resolves_a_freshly_loaded_rule_set() {
158        let (_dir, root) = workspace_with_two_rules();
159        let ws = Workspace::load(root).expect("load");
160        assert!(ws.rule_set_id_at(0).is_some());
161        assert!(ws.rule_set_id_at(1).is_none(), "only one rule set exists");
162    }
163
164    #[test]
165    fn rule_id_at_resolves_a_rule_seeded_at_load_not_just_one_added_this_session() {
166        let (_dir, root) = workspace_with_two_rules();
167        let ws = Workspace::load(root).expect("load");
168        assert!(ws.rule_id_at(0, 0).is_some());
169        assert!(ws.rule_id_at(0, 1).is_some());
170        assert!(
171            ws.rule_id_at(0, 2).is_none(),
172            "out-of-range rule index must resolve to None, not panic"
173        );
174    }
175
176    #[test]
177    fn describe_never_contains_a_uuid_shaped_substring() {
178        let (_dir, root) = workspace_with_two_rules();
179        let mut ws = Workspace::load(root).expect("load");
180        let parent = ws.rule_set_id_at(0).expect("rule set 0 exists");
181        let result = ws
182            .apply(EditCommand::AddRule {
183                parent,
184                rule: RulePayload {
185                    url_path: Some("/new".to_owned()),
186                    url_path_op: None,
187                    method: None,
188                    priority: None,
189                    headers: None,
190                    body: None,
191                    respond: RespondPayload {
192                        text: Some("ok".to_owned()),
193                        ..Default::default()
194                    },
195                },
196            })
197            .expect("apply");
198
199        for id in result.changed_nodes {
200            let label = ws.describe(id).expect("every changed node describes");
201            assert!(
202                !looks_like_a_uuid(&label),
203                "describe() must never render a UUID: {label}"
204            );
205        }
206    }
207
208    fn looks_like_a_uuid(s: &str) -> bool {
209        // 8-4-4-4-12 hex groups joined by hyphens.
210        let groups: Vec<&str> = s.split('-').collect();
211        groups.len() >= 5
212            && groups.windows(5).any(|w| {
213                w.iter()
214                    .all(|g| !g.is_empty() && g.chars().all(|c| c.is_ascii_hexdigit()))
215            })
216    }
217
218    #[test]
219    fn preview_changes_matches_what_save_would_report() {
220        let (_dir, root) = workspace_with_two_rules();
221        let mut ws = Workspace::load(root).expect("load");
222        let parent = ws.rule_set_id_at(0).expect("rule set 0 exists");
223        ws.apply(EditCommand::AddRule {
224            parent,
225            rule: RulePayload {
226                url_path: Some("/preview".to_owned()),
227                url_path_op: None,
228                method: None,
229                priority: None,
230                headers: None,
231                body: None,
232                respond: RespondPayload {
233                    text: Some("ok".to_owned()),
234                    ..Default::default()
235                },
236            },
237        })
238        .expect("apply");
239
240        let preview = ws.preview_changes();
241        assert!(!preview.is_empty(), "an applied add should show in preview");
242        let save = ws.save().expect("save");
243        assert_eq!(
244            preview.len(),
245            save.diff_summary.len(),
246            "preview_changes must match what save() then actually reports"
247        );
248    }
249}