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.
90pub(super) fn respond_node_validation(
91    respond: &apimock_routing::Respond,
92    rule_set: &RuleSet,
93    rule_idx: usize,
94    rs_idx: usize,
95) -> NodeValidation {
96    // `Respond::validate` logs errors but returns a bool. For 5.1
97    // per-node validation we want structured messages — so we replicate
98    // the specific checks here rather than piping through the logger.
99    let mut issues: Vec<ValidationIssue> = Vec::new();
100
101    let any = respond.file_path.is_some() || respond.text.is_some() || respond.status.is_some();
102    if !any {
103        issues.push(ValidationIssue {
104            severity: Severity::Error,
105            message: "response requires at least one of file_path, text, or status".to_owned(),
106        });
107    }
108    if respond.file_path.is_some() && respond.text.is_some() {
109        issues.push(ValidationIssue {
110            severity: Severity::Error,
111            message: "file_path and text cannot both be set".to_owned(),
112        });
113    }
114    if respond.file_path.is_some() && respond.status.is_some() {
115        issues.push(ValidationIssue {
116            severity: Severity::Error,
117            message: "status cannot be combined with file_path (only with text)".to_owned(),
118        });
119    }
120
121    // file-existence validation: this is the same behaviour the old
122    // `Respond::validate(dir_prefix, …)` performed. We don't call it
123    // directly because it writes to `log::error!`, which would flood
124    // the console during every GUI snapshot.
125    if let Some(file_path) = respond.file_path.as_ref() {
126        let dir_prefix = rule_set.dir_prefix();
127        let p = Path::new(dir_prefix.as_str()).join(file_path);
128        if !p.exists() {
129            issues.push(ValidationIssue {
130                severity: Severity::Error,
131                message: format!(
132                    "file not found: {} (rule #{} in rule set #{})",
133                    p.to_string_lossy(),
134                    rule_idx + 1,
135                    rs_idx + 1,
136                ),
137            });
138        }
139    }
140
141    NodeValidation {
142        ok: issues.is_empty(),
143        issues,
144    }
145}