1use guard_lang::Status;
11use guard_lang::UnResolved;
12use guard_lang::eval::eval_rules_file;
13use guard_lang::eval_context::{
14 BinaryCheck, ClauseReport, GuardClauseReport, Messages, UnaryCheck, root_scope, simplified_json_from_root,
15};
16use guard_lang::exprs::{Block, GuardClause, Rule, RuleClause, RulesFile};
17use guard_lang::parser::{Span, rules_file};
18use guard_lang::path_value::{Path as GuardPath, PathAwareValue};
19use std::fs;
20use std::path::Path;
21use std::rc::Rc;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct GuardRuleInfo {
26 pub name: String,
27 pub custom_message: Option<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct GuardFinding {
35 pub rule_name: String,
37 pub path: Option<String>,
43 pub missing_query: Option<String>,
46 pub custom_message: Option<String>,
48 pub check: String,
51}
52
53#[derive(Debug, Clone)]
59pub struct GuardRuleFile {
60 name: String,
61 pack: String,
62 source: String,
63 rules: Vec<GuardRuleInfo>,
64}
65
66impl GuardRuleFile {
67 pub fn parse(name: impl Into<String>, source: impl Into<String>) -> Result<Self, String> {
70 let name = name.into();
71 let source = source.into();
72 let rules = match parse_rules(&source, &name)? {
73 Some(rules_file) => rules_file
74 .guard_rules
75 .iter()
76 .map(|rule| GuardRuleInfo { name: rule.rule_name.clone(), custom_message: first_custom_message(rule) })
77 .collect(),
78 None => Vec::new(),
79 };
80 let pack = pack_name_from_path(&name);
81 Ok(Self { name, pack, source, rules })
82 }
83
84 pub fn name(&self) -> &str {
85 &self.name
86 }
87
88 pub fn pack(&self) -> &str {
90 &self.pack
91 }
92
93 pub fn rules(&self) -> &[GuardRuleInfo] {
94 &self.rules
95 }
96
97 pub fn evaluate(&self, template: &serde_json::Value) -> Result<Vec<GuardFinding>, String> {
101 let Some(rules_file) = parse_rules(&self.source, &self.name)? else {
102 return Ok(Vec::new());
103 };
104 let root = PathAwareValue::try_from((template, GuardPath::root()))
105 .map_err(|error| format!("Failed to load the template for Guard file '{}': {}", self.name, error))?;
106 let mut scope = root_scope(&rules_file, Rc::new(root));
107 let status = eval_rules_file(&rules_file, &mut scope, Some(&self.name))
108 .map_err(|error| format!("Guard file '{}' failed to evaluate: {}", self.name, error))?;
109 if status != Status::FAIL {
110 return Ok(Vec::new());
111 }
112 let mut recorder = scope.reset_recorder();
113 let record = recorder
114 .final_event
115 .take()
116 .ok_or_else(|| format!("Guard file '{}' produced no evaluation record", self.name))?;
117 let report = simplified_json_from_root(&record).map_err(|error| {
118 format!("Guard file '{}' produced an unreadable evaluation record: {}", self.name, error)
119 })?;
120
121 let mut findings = Vec::new();
122 for failed_rule in &report.not_compliant {
123 if let ClauseReport::Rule(rule) = failed_rule {
124 for check in &rule.checks {
125 collect_findings(check, rule.name, &mut findings);
126 }
127 }
128 }
129 Ok(findings)
130 }
131}
132
133fn parse_rules<'source>(source: &'source str, name: &'source str) -> Result<Option<RulesFile<'source>>, String> {
134 rules_file(Span::new_extra(source, name))
135 .map_err(|error| format!("Failed to parse Guard file '{}': {}", name, error))
136}
137
138fn first_custom_message(rule: &Rule<'_>) -> Option<String> {
139 rule.block.conjunctions.iter().flatten().find_map(|clause| match clause {
140 RuleClause::Clause(guard_clause) => guard_clause_custom_message(guard_clause),
141 RuleClause::WhenBlock(_, block) => block_custom_message(block),
142 RuleClause::TypeBlock(type_block) => block_custom_message(&type_block.block),
143 })
144}
145
146fn block_custom_message(block: &Block<'_, GuardClause<'_>>) -> Option<String> {
147 block.conjunctions.iter().flatten().find_map(guard_clause_custom_message)
148}
149
150fn guard_clause_custom_message(clause: &GuardClause<'_>) -> Option<String> {
151 match clause {
152 GuardClause::Clause(access) => access.access_clause.custom_message.clone(),
153 GuardClause::NamedRule(named) => named.custom_message.clone(),
154 GuardClause::ParameterizedNamedRule(parameterized) => parameterized.named_rule.custom_message.clone(),
155 GuardClause::BlockClause(block) => block_custom_message(&block.block),
156 GuardClause::WhenBlock(_, block) => block_custom_message(block),
157 }
158}
159
160fn collect_findings(report: &ClauseReport<'_>, rule_name: &str, findings: &mut Vec<GuardFinding>) {
166 match report {
167 ClauseReport::Rule(nested) => {
168 for check in &nested.checks {
169 collect_findings(check, rule_name, findings);
170 }
171 }
172 ClauseReport::Disjunctions(alternatives) => {
173 let mut alternative_findings = Vec::new();
174 for alternative in &alternatives.checks {
175 collect_findings(alternative, rule_name, &mut alternative_findings);
176 }
177 if let Some(merged) = merge_alternatives(alternative_findings) {
178 findings.push(merged);
179 }
180 }
181 ClauseReport::Block(block) => {
182 let (path, missing_query) = match &block.unresolved {
183 Some(unresolved) => unresolved_location(unresolved),
184 None => (None, None),
185 };
186 let check = match &block.unresolved {
187 Some(unresolved) => unresolved.remaining_query.clone(),
188 None => normalize_whitespace(&block.context),
189 };
190 findings.push(GuardFinding {
191 rule_name: rule_name.to_string(),
192 path,
193 missing_query,
194 custom_message: custom_message(&block.messages),
195 check,
196 });
197 }
198 ClauseReport::Clause(GuardClauseReport::Unary(unary)) => {
199 let (path, missing_query) = match &unary.check {
200 UnaryCheck::Resolved(comparison) => (Some(template_path(&comparison.value)), None),
201 UnaryCheck::UnResolved(unresolved) => unresolved_location(&unresolved.value),
202 UnaryCheck::UnResolvedContext(_) => (None, None),
203 };
204 findings.push(GuardFinding {
205 rule_name: rule_name.to_string(),
206 path,
207 missing_query,
208 custom_message: custom_message(&unary.messages),
209 check: normalize_whitespace(&unary.context),
210 });
211 }
212 ClauseReport::Clause(GuardClauseReport::Binary(binary)) => {
213 let (path, missing_query) = match &binary.check {
214 BinaryCheck::Resolved(comparison) => (Some(template_path(&comparison.from)), None),
215 BinaryCheck::InResolved(comparison) => (Some(template_path(&comparison.from)), None),
216 BinaryCheck::UnResolved(unresolved) => unresolved_location(&unresolved.value),
217 };
218 findings.push(GuardFinding {
219 rule_name: rule_name.to_string(),
220 path,
221 missing_query,
222 custom_message: custom_message(&binary.messages),
223 check: normalize_whitespace(&binary.context),
224 });
225 }
226 }
227}
228
229fn merge_alternatives(alternatives: Vec<GuardFinding>) -> Option<GuardFinding> {
230 let mut alternatives = alternatives.into_iter();
231 let mut merged = alternatives.next()?;
232 for alternative in alternatives {
233 if merged.path.is_none() {
234 merged.path = alternative.path;
235 merged.missing_query = alternative.missing_query;
236 }
237 if merged.custom_message.is_none() {
238 merged.custom_message = alternative.custom_message;
239 }
240 merged.check.push_str(" OR ");
241 merged.check.push_str(&alternative.check);
242 }
243 Some(merged)
244}
245
246fn unresolved_location(unresolved: &UnResolved) -> (Option<String>, Option<String>) {
247 (Some(template_path(&unresolved.traversed_to)), Some(unresolved.remaining_query.clone()))
248}
249
250fn template_path(value: &PathAwareValue) -> String {
253 value.self_path().0.trim_start_matches('/').to_string()
254}
255
256fn custom_message(messages: &Messages) -> Option<String> {
258 messages.custom_message.as_deref().map(str::trim).filter(|message| !message.is_empty()).map(str::to_string)
259}
260
261fn normalize_whitespace(text: &str) -> String {
262 text.split_whitespace().collect::<Vec<_>>().join(" ")
263}
264
265pub fn load_guard_sources_recursive(dir: &str) -> Result<Vec<(String, String)>, String> {
267 let path = Path::new(dir);
268 if !path.is_dir() {
269 return Err(format!("Guard rule directory not found: {}", dir));
270 }
271 let mut sources = Vec::new();
272 collect_guard_files_recursive(path, &mut sources)?;
273 if sources.is_empty() {
274 return Err(format!("No .guard files found in directory (recursive): {}", dir));
275 }
276 sources.sort_by(|a, b| a.0.cmp(&b.0));
277 Ok(sources)
278}
279
280fn collect_guard_files_recursive(dir: &Path, out: &mut Vec<(String, String)>) -> Result<(), String> {
281 let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))?;
282 for entry in entries {
283 let entry = entry.map_err(|e| format!("Failed to read entry in '{}': {}", dir.display(), e))?;
284 let path = entry.path();
285 if path.is_dir() {
286 collect_guard_files_recursive(&path, out)?;
287 } else if path.extension().and_then(|e| e.to_str()) == Some("guard") {
288 let path_str = path.display().to_string();
289 let content = fs::read_to_string(&path).map_err(|e| format!("Failed to read '{}': {}", path_str, e))?;
290 out.push((path_str, content));
291 }
292 }
293 Ok(())
294}
295
296pub fn pack_name_from_path(path: &str) -> String {
304 let file_name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
305 let stem = file_name.trim_end_matches(".guard").trim_end_matches(".ruleset");
306 stem.chars().map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }).collect()
307}