apimock_config/workspace/validate.rs
1//! `Workspace::validate()` and the per-node validation walker.
2//!
3//! # Why per-node validation lives here, not in `Respond` itself
4//!
5//! The routing crate's `Respond::validate()` writes errors to
6//! `log::error!` and returns a bool. That's good enough for startup
7//! validation (where the user reads stderr), but a GUI needs
8//! structured `(severity, message, target_id)` triples it can render
9//! inline. We replicate the rule logic here so the GUI gets diagnostic
10//! objects without flooding the log every snapshot.
11//!
12//! # Used by both `validate()` and `snapshot()`
13//!
14//! The same `respond_node_validation` function backs both code paths,
15//! so a node rendered with a red underline in the snapshot will also
16//! appear in `ValidationReport::diagnostics`. Single source of truth.
17
18use std::path::{Path, PathBuf};
19
20use apimock_routing::RuleSet;
21
22use crate::view::{Diagnostic, NodeValidation, Severity, ValidationIssue, ValidationReport};
23
24use super::Workspace;
25use super::id_index::NodeAddress;
26
27impl Workspace {
28 /// Walk every node, asking it for its validation state, and return
29 /// the flat list of issues. Used at apply-time and on demand from
30 /// `validate()`.
31 pub(super) fn collect_diagnostics(&self) -> Vec<Diagnostic> {
32 let mut out: Vec<Diagnostic> = Vec::new();
33 for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
34 for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
35 let nv = respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx);
36 if nv.ok {
37 continue;
38 }
39 let resp_id = self.ids.id_for(NodeAddress::Respond {
40 rule_set: rs_idx,
41 rule: rule_idx,
42 });
43 for issue in nv.issues {
44 out.push(Diagnostic {
45 node_id: resp_id,
46 file: Some(PathBuf::from(rule_set.file_path.as_str())),
47 severity: issue.severity,
48 message: issue.message,
49 });
50 }
51 }
52 }
53
54 // Root-level check: fallback_respond_dir must exist.
55 if !Path::new(self.config.service.fallback_respond_dir.as_str()).exists() {
56 out.push(Diagnostic {
57 node_id: self.ids.id_for(NodeAddress::FallbackRespondDir),
58 file: Some(self.root_path.clone()),
59 severity: Severity::Error,
60 message: format!(
61 "fallback_respond_dir does not exist: {}",
62 self.config.service.fallback_respond_dir
63 ),
64 });
65 }
66
67 out
68 }
69
70 // --- Public API ----
71
72 /// Validate the workspace and return a GUI-ready report.
73 ///
74 /// Uses the same per-node checks `snapshot()` does so the numbers
75 /// line up: a node rendered with a red underline in the snapshot
76 /// will appear in `report.diagnostics` with the same message.
77 pub fn validate(&self) -> ValidationReport {
78 let diagnostics = self.collect_diagnostics();
79 let is_valid = !diagnostics
80 .iter()
81 .any(|d| matches!(d.severity, Severity::Error));
82 ValidationReport {
83 diagnostics,
84 is_valid,
85 }
86 }
87}
88
89/// Build a `NodeValidation` for one `Respond` block.
90///
91/// # RFC 065 — kept in step with `Respond::validate` by hand
92///
93/// `json` is a third mutually-exclusive body source alongside
94/// `file_path`/`text` — every check below that used to enumerate two
95/// now enumerates three, and inline `json` / a referenced `.json`
96/// file's content get the same JSON5-parse check `Respond::validate`
97/// does at load time, so a GUI diagnostic and `apimock validate`'s own
98/// pass never disagree about the same rule. Caught by this RFC's own
99/// tests: a config using `respond.json` loaded fine (`Respond::validate`
100/// already knew about `json`) but `apimock validate` still reported
101/// "requires at least one of file_path, text, or status" and exited 1
102/// — this function's own copy of the check hadn't been told `json`
103/// existed.
104pub(super) fn respond_node_validation(
105 respond: &apimock_routing::Respond,
106 rule_set: &RuleSet,
107 rule_idx: usize,
108 rs_idx: usize,
109) -> NodeValidation {
110 // `Respond::validate` logs errors but returns a `Result<(), String>`
111 // (see its own doc comment). For 5.1 per-node validation we want
112 // structured messages — so we replicate the specific checks here
113 // rather than piping through that string.
114 let mut issues: Vec<ValidationIssue> = Vec::new();
115
116 let any = respond.file_path.is_some()
117 || respond.text.is_some()
118 || respond.json.is_some()
119 || respond.status.is_some();
120 if !any {
121 issues.push(ValidationIssue {
122 severity: Severity::Error,
123 message: "response requires at least one of file_path, text, json, or status"
124 .to_owned(),
125 });
126 }
127 let body_sources_set = [
128 respond.file_path.is_some(),
129 respond.text.is_some(),
130 respond.json.is_some(),
131 ]
132 .into_iter()
133 .filter(|&set| set)
134 .count();
135 if body_sources_set > 1 {
136 issues.push(ValidationIssue {
137 severity: Severity::Error,
138 message: "file_path, text and json are mutually exclusive; only one may be set"
139 .to_owned(),
140 });
141 }
142 if respond.file_path.is_some() && respond.status.is_some() {
143 issues.push(ValidationIssue {
144 severity: Severity::Error,
145 message: "status cannot be combined with file_path (only with text or json)".to_owned(),
146 });
147 }
148
149 if let Some(json_str) = respond.json.as_ref()
150 && let Err(e) = json5::from_str::<serde_json::Value>(json_str)
151 {
152 issues.push(ValidationIssue {
153 severity: Severity::Error,
154 message: format!(
155 "invalid json (rule #{} in rule set #{}): {}",
156 rule_idx + 1,
157 rs_idx + 1,
158 e
159 ),
160 });
161 }
162
163 // file-existence (and, for `.json`/`.json5`, content) validation:
164 // the same behaviour `Respond::validate(dir_prefix, …)` performs.
165 // We don't call it directly because it writes to `log::error!`,
166 // which would flood the console during every GUI snapshot.
167 if let Some(file_path) = respond.file_path.as_ref() {
168 let dir_prefix = rule_set.dir_prefix();
169 let p = Path::new(dir_prefix.as_str()).join(file_path);
170 if !p.exists() {
171 issues.push(ValidationIssue {
172 severity: Severity::Error,
173 message: format!(
174 "file not found: {} (rule #{} in rule set #{})",
175 display_path(&p),
176 rule_idx + 1,
177 rs_idx + 1,
178 ),
179 });
180 } else {
181 let is_json_like = p
182 .extension()
183 .and_then(|e| e.to_str())
184 .map(|e| e.to_ascii_lowercase())
185 .is_some_and(|e| e == "json" || e == "json5");
186 if is_json_like {
187 match std::fs::read_to_string(&p) {
188 Ok(content) => {
189 if let Err(e) = json5::from_str::<serde_json::Value>(content.as_str()) {
190 issues.push(ValidationIssue {
191 severity: Severity::Error,
192 message: format!(
193 "`{}` is not valid JSON (rule #{} in rule set #{}): {}",
194 display_path(&p),
195 rule_idx + 1,
196 rs_idx + 1,
197 e
198 ),
199 });
200 }
201 }
202 Err(e) => {
203 issues.push(ValidationIssue {
204 severity: Severity::Error,
205 message: format!(
206 "failed to read `{}` (rule #{} in rule set #{}): {}",
207 display_path(&p),
208 rule_idx + 1,
209 rs_idx + 1,
210 e
211 ),
212 });
213 }
214 }
215 }
216 }
217 }
218
219 NodeValidation {
220 ok: issues.is_empty(),
221 issues,
222 }
223}
224
225/// `Path::join` performs no normalisation — `RuleSet::dir_prefix()` is
226/// itself already `"./."` for the common case (no `[prefix]` block, a
227/// config sitting in the current directory), so joining it with a bare
228/// `file_path` renders as `"././bad.json"` in a diagnostic message
229/// (RFC 065 review, F-1). Mirrors `apimock_routing::rule_set::rule::
230/// respond`'s own private `display_path` — not shared across the crate
231/// boundary for one small formatting helper neither side has any
232/// reason to keep depending on the other for.
233fn display_path(p: &Path) -> String {
234 use std::path::Component;
235 let cleaned: PathBuf = p
236 .components()
237 .filter(|c| !matches!(c, Component::CurDir))
238 .collect();
239 if cleaned.as_os_str().is_empty() {
240 ".".to_owned()
241 } else {
242 cleaned.to_string_lossy().into_owned()
243 }
244}