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::{
23    Diagnostic, NodeValidation, Severity, ValidationIssue, ValidationReport,
24};
25
26use super::Workspace;
27use super::id_index::NodeAddress;
28
29impl Workspace {
30    /// Walk every node, asking it for its validation state, and return
31    /// the flat list of issues. Used at apply-time and on demand from
32    /// `validate()`.
33    pub(super) fn collect_diagnostics(&self) -> Vec<Diagnostic> {
34        let mut out: Vec<Diagnostic> = Vec::new();
35        for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
36            for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
37                let nv = respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx);
38                if nv.ok {
39                    continue;
40                }
41                let resp_id = self.ids.id_for(NodeAddress::Respond {
42                    rule_set: rs_idx,
43                    rule: rule_idx,
44                });
45                for issue in nv.issues {
46                    out.push(Diagnostic {
47                        node_id: resp_id,
48                        file: Some(PathBuf::from(rule_set.file_path.as_str())),
49                        severity: issue.severity,
50                        message: issue.message,
51                    });
52                }
53            }
54        }
55
56        // Root-level check: fallback_respond_dir must exist.
57        if !Path::new(self.config.service.fallback_respond_dir.as_str()).exists() {
58            out.push(Diagnostic {
59                node_id: self.ids.id_for(NodeAddress::FallbackRespondDir),
60                file: Some(self.root_path.clone()),
61                severity: Severity::Error,
62                message: format!(
63                    "fallback_respond_dir does not exist: {}",
64                    self.config.service.fallback_respond_dir
65                ),
66            });
67        }
68
69        out
70    }
71
72    // --- Public API ----
73
74    /// Validate the workspace and return a GUI-ready report.
75    ///
76    /// Uses the same per-node checks `snapshot()` does so the numbers
77    /// line up: a node rendered with a red underline in the snapshot
78    /// will appear in `report.diagnostics` with the same message.
79    pub fn validate(&self) -> ValidationReport {
80        let diagnostics = self.collect_diagnostics();
81        let is_valid = !diagnostics
82            .iter()
83            .any(|d| matches!(d.severity, Severity::Error));
84        ValidationReport {
85            diagnostics,
86            is_valid,
87        }
88    }
89}
90
91/// Build a `NodeValidation` for one `Respond` block.
92pub(super) fn respond_node_validation(
93    respond: &apimock_routing::Respond,
94    rule_set: &RuleSet,
95    rule_idx: usize,
96    rs_idx: usize,
97) -> NodeValidation {
98    // `Respond::validate` logs errors but returns a bool. For 5.1
99    // per-node validation we want structured messages — so we replicate
100    // the specific checks here rather than piping through the logger.
101    let mut issues: Vec<ValidationIssue> = Vec::new();
102
103    let any = respond.file_path.is_some() || respond.text.is_some() || respond.status.is_some();
104    if !any {
105        issues.push(ValidationIssue {
106            severity: Severity::Error,
107            message: "response requires at least one of file_path, text, or status".to_owned(),
108        });
109    }
110    if respond.file_path.is_some() && respond.text.is_some() {
111        issues.push(ValidationIssue {
112            severity: Severity::Error,
113            message: "file_path and text cannot both be set".to_owned(),
114        });
115    }
116    if respond.file_path.is_some() && respond.status.is_some() {
117        issues.push(ValidationIssue {
118            severity: Severity::Error,
119            message: "status cannot be combined with file_path (only with text)".to_owned(),
120        });
121    }
122
123    // file-existence validation: this is the same behaviour the old
124    // `Respond::validate(dir_prefix, …)` performed. We don't call it
125    // directly because it writes to `log::error!`, which would flood
126    // the console during every GUI snapshot.
127    if let Some(file_path) = respond.file_path.as_ref() {
128        let dir_prefix = rule_set.dir_prefix();
129        let p = Path::new(dir_prefix.as_str()).join(file_path);
130        if !p.exists() {
131            issues.push(ValidationIssue {
132                severity: Severity::Error,
133                message: format!(
134                    "file not found: {} (rule #{} in rule set #{})",
135                    p.to_string_lossy(),
136                    rule_idx + 1,
137                    rs_idx + 1,
138                ),
139            });
140        }
141    }
142
143    NodeValidation {
144        ok: issues.is_empty(),
145        issues,
146    }
147}