Skip to main content

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}