Skip to main content

apimock_config/workspace/
snapshot.rs

1//! `Workspace::snapshot()` and the per-file view builders it composes.
2//!
3//! # Why this is a separate module
4//!
5//! Snapshot is read-only; it owns no mutating logic. Keeping it apart
6//! from the edit / save modules makes the read path easy to reason
7//! about — there's no chance a snapshot helper accidentally mutates
8//! the model because the `&self` receiver here can't.
9//!
10//! # Per-node validation runs here too
11//!
12//! `rule_set_file_view` calls into [`crate::workspace::validate`]
13//! to attach `NodeValidation` to each respond node. That means
14//! snapshot rendering and `validate()` walk the same checks; a node
15//! marked invalid in the snapshot will also appear in
16//! `ValidationReport::diagnostics`.
17
18use std::path::PathBuf;
19
20use apimock_routing::RuleSet;
21
22use crate::view::{
23    ConfigFileKind, ConfigFileView, ConfigNodeView, NodeKind, NodeValidation, WorkspaceSnapshot,
24};
25
26use super::Workspace;
27use super::id_index::NodeAddress;
28use super::path_helpers::file_basename;
29use super::validate::respond_node_validation;
30
31impl Workspace {
32    /// Build a snapshot for GUI rendering.
33    ///
34    /// # Allocation cost
35    ///
36    /// A snapshot fully owns its data (no borrows into the workspace)
37    /// so the GUI can serialise / send / store it without lifetime
38    /// gymnastics. This is O(total editable nodes) allocation per
39    /// call; the GUI should call it once per edit, not once per
40    /// render frame.
41    pub fn snapshot(&self) -> WorkspaceSnapshot {
42        let mut files: Vec<ConfigFileView> = Vec::new();
43
44        // Root file.
45        if let Some(root_nodes) = self.root_file_nodes() {
46            files.push(root_nodes);
47        }
48
49        // Rule-set files.
50        for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
51            files.push(self.rule_set_file_view(rs_idx, rule_set));
52        }
53
54        // Middleware files. We don't introspect them beyond their path
55        // existence; the Rhai AST is a server-side concern.
56        if let Some(paths) = self.config.service.middlewares_file_paths.as_ref() {
57            for (mw_idx, mw_path) in paths.iter().enumerate() {
58                let abs = self.resolve_relative(mw_path);
59                let id = self
60                    .ids
61                    .id_for(NodeAddress::Middleware { middleware: mw_idx })
62                    .expect("middleware id seeded at load");
63                let node = ConfigNodeView {
64                    id,
65                    source_file: abs.clone(),
66                    toml_path: format!("service.middlewares[{}]", mw_idx),
67                    display_name: mw_path.clone(),
68                    kind: NodeKind::Script,
69                    validation: NodeValidation::ok(),
70                };
71                files.push(ConfigFileView {
72                    path: abs.clone(),
73                    display_name: file_basename(&abs),
74                    kind: ConfigFileKind::Middleware,
75                    nodes: vec![node],
76                });
77            }
78        }
79
80        // Route catalog — assemble from rule sets, fallback dir,
81        // file tree (depth-1 eager), and middleware script routes.
82        // Builders live in `apimock_routing::view::build`; the config
83        // crate just feeds them the data they need.
84        let fallback_dir = self.config.service.fallback_respond_dir.as_str();
85        let fallback_abs = self.resolve_relative(fallback_dir);
86        let filter = self
87            .config
88            .file_tree_view
89            .as_ref()
90            .map(|c| c.to_filter())
91            .unwrap_or_default();
92        let file_tree =
93            apimock_routing::view::build::build_file_tree_with(&fallback_abs, &filter);
94
95        let script_routes: Vec<apimock_routing::view::ScriptRouteView> = self
96            .config
97            .service
98            .middlewares_file_paths
99            .as_ref()
100            .map(|paths| {
101                paths
102                    .iter()
103                    .enumerate()
104                    .map(|(idx, p)| apimock_routing::view::build::build_script_route_view(idx, p))
105                    .collect()
106            })
107            .unwrap_or_default();
108
109        let routes = apimock_routing::view::build::build_route_catalog(
110            &self.config.service.rule_sets,
111            Some(fallback_dir),
112            file_tree,
113            script_routes,
114        );
115
116        WorkspaceSnapshot {
117            files,
118            routes,
119            diagnostics: self.diagnostics.clone(),
120        }
121    }
122    /// Root config file as a `ConfigFileView`, if it can be rendered.
123    fn root_file_nodes(&self) -> Option<ConfigFileView> {
124        let mut nodes = Vec::new();
125
126        if let Some(root_id) = self.ids.id_for(NodeAddress::Root) {
127            nodes.push(ConfigNodeView {
128                id: root_id,
129                source_file: self.root_path.clone(),
130                toml_path: String::new(),
131                display_name: "apimock.toml".to_owned(),
132                kind: NodeKind::RootSetting,
133                validation: NodeValidation::ok(),
134            });
135        }
136
137        if let Some(fb_id) = self.ids.id_for(NodeAddress::FallbackRespondDir) {
138            nodes.push(ConfigNodeView {
139                id: fb_id,
140                source_file: self.root_path.clone(),
141                toml_path: "service.fallback_respond_dir".to_owned(),
142                display_name: self.config.service.fallback_respond_dir.clone(),
143                kind: NodeKind::FileNode,
144                validation: NodeValidation::ok(),
145            });
146        }
147
148        Some(ConfigFileView {
149            path: self.root_path.clone(),
150            display_name: file_basename(&self.root_path),
151            kind: ConfigFileKind::Root,
152            nodes,
153        })
154    }
155
156    fn rule_set_file_view(&self, rs_idx: usize, rule_set: &RuleSet) -> ConfigFileView {
157        let file_path = PathBuf::from(rule_set.file_path.as_str());
158        let mut nodes: Vec<ConfigNodeView> = Vec::new();
159
160        // Rule-set itself.
161        if let Some(rs_id) = self
162            .ids
163            .id_for(NodeAddress::RuleSet { rule_set: rs_idx })
164        {
165            nodes.push(ConfigNodeView {
166                id: rs_id,
167                source_file: file_path.clone(),
168                toml_path: String::new(),
169                display_name: file_basename(&file_path),
170                kind: NodeKind::RuleSet,
171                validation: NodeValidation::ok(),
172            });
173        }
174
175        // Rules inside.
176        for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
177            if let Some(rule_id) = self.ids.id_for(NodeAddress::Rule {
178                rule_set: rs_idx,
179                rule: rule_idx,
180            }) {
181                let url_path_label = rule
182                    .when
183                    .request
184                    .url_path
185                    .as_ref()
186                    .map(|u| u.value.as_str())
187                    .unwrap_or_default();
188                let display = if url_path_label.is_empty() {
189                    format!("Rule #{}", rule_idx + 1)
190                } else {
191                    url_path_label.to_owned()
192                };
193                nodes.push(ConfigNodeView {
194                    id: rule_id,
195                    source_file: file_path.clone(),
196                    toml_path: format!("rules[{}]", rule_idx),
197                    display_name: display,
198                    kind: NodeKind::Rule,
199                    validation: NodeValidation::ok(),
200                });
201            }
202
203            if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
204                rule_set: rs_idx,
205                rule: rule_idx,
206            }) {
207                nodes.push(ConfigNodeView {
208                    id: resp_id,
209                    source_file: file_path.clone(),
210                    toml_path: format!("rules[{}].respond", rule_idx),
211                    display_name: summarise_respond(&rule.respond),
212                    kind: NodeKind::Respond,
213                    validation: respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx),
214                });
215            }
216        }
217
218        ConfigFileView {
219            path: file_path.clone(),
220            display_name: file_basename(&file_path),
221            kind: ConfigFileKind::RuleSet,
222            nodes,
223        }
224    }
225}
226
227fn summarise_respond(respond: &apimock_routing::Respond) -> String {
228    if let Some(p) = respond.file_path.as_ref() {
229        return format!("file: {}", p);
230    }
231    if let Some(t) = respond.text.as_ref() {
232        const LIMIT: usize = 40;
233        if t.chars().count() > LIMIT {
234            let truncated: String = t.chars().take(LIMIT).collect();
235            return format!("text: {}…", truncated);
236        }
237        return format!("text: {}", t);
238    }
239    if let Some(s) = respond.status.as_ref() {
240        return format!("status: {}", s);
241    }
242    "(empty)".to_owned()
243}