1use crate::error::EngineErrorSource;
7use crate::literals::Value;
8use crate::parsing::ast::{
9 DataValue, Expression, ExpressionKind, LemmaData, LemmaRule, LemmaSpec, ParentType,
10 PrimitiveKind, Span, TypeConstraintCommand,
11};
12use crate::parsing::source::{Source, SourceType};
13use crate::Engine;
14use serde::{Deserialize, Serialize};
15use std::fmt;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Recommendation {
23 pub message: String,
25 pub repository: Option<String>,
26 pub spec: String,
27 pub effective_from: Option<crate::parsing::ast::DateTimeValue>,
28 pub source_location: Source,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32struct RecommendationWire {
33 message: String,
34 repository: Option<String>,
35 spec: String,
36 effective_from: Option<crate::parsing::ast::DateTimeValue>,
37 source: EngineErrorSource,
38}
39
40impl From<&Recommendation> for RecommendationWire {
41 fn from(r: &Recommendation) -> Self {
42 Self {
43 message: r.message.clone(),
44 repository: r.repository.clone(),
45 spec: r.spec.clone(),
46 effective_from: r.effective_from.clone(),
47 source: EngineErrorSource::from(&r.source_location),
48 }
49 }
50}
51
52impl From<RecommendationWire> for Recommendation {
53 fn from(w: RecommendationWire) -> Self {
54 let source_type =
55 SourceType::from_binding_label(&w.source.attribute).unwrap_or(SourceType::Volatile);
56 let end = w.source.length;
57 Self {
58 message: w.message,
59 repository: w.repository,
60 spec: w.spec,
61 effective_from: w.effective_from,
62 source_location: Source::new(
63 source_type,
64 Span {
65 start: 0,
66 end,
67 line: w.source.line,
68 col: w.source.column,
69 },
70 ),
71 }
72 }
73}
74
75impl Serialize for Recommendation {
76 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
77 RecommendationWire::from(self).serialize(serializer)
78 }
79}
80
81impl<'de> Deserialize<'de> for Recommendation {
82 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
83 RecommendationWire::deserialize(deserializer).map(Self::from)
84 }
85}
86
87impl fmt::Display for Recommendation {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 write!(f, "In spec '{}'", self.spec)?;
90 match &self.effective_from {
91 Some(dt) => write!(f, " (effective from {dt})")?,
92 None => write!(f, " (effective from beginning)")?,
93 }
94 write!(f, ": {}", self.message)?;
95 write!(
96 f,
97 " at {}:{}:{}",
98 self.source_location.source_type,
99 self.source_location.span.line,
100 self.source_location.span.col
101 )
102 }
103}
104
105impl Engine {
106 #[must_use]
111 pub fn quality(&self) -> Vec<Recommendation> {
112 let mut out = Vec::new();
113 for (repository, inner) in self.context.repositories().iter() {
114 if repository.dependency.is_some() {
115 continue;
116 }
117 let repo_name = repository.name.clone();
118 for (_, spec_set) in inner.iter() {
119 for spec in spec_set.iter_specs() {
120 analyze_spec(repo_name.clone(), spec, &mut out);
121 }
122 }
123 }
124 out.sort_by(|a, b| {
125 (
126 a.repository.as_deref().unwrap_or(""),
127 a.spec.as_str(),
128 a.source_location.span.line,
129 a.source_location.span.col,
130 a.source_location.span.start,
131 )
132 .cmp(&(
133 b.repository.as_deref().unwrap_or(""),
134 b.spec.as_str(),
135 b.source_location.span.line,
136 b.source_location.span.col,
137 b.source_location.span.start,
138 ))
139 });
140 out
141 }
142}
143
144fn analyze_spec(repository: Option<String>, spec: &LemmaSpec, out: &mut Vec<Recommendation>) {
145 for data in &spec.data {
146 analyze_data(repository.clone(), spec, data, out);
147 }
148 for rule in &spec.rules {
149 analyze_rule(repository.clone(), spec, rule, out);
150 }
151}
152
153fn analyze_data(
154 repository: Option<String>,
155 spec: &LemmaSpec,
156 data: &LemmaData,
157 out: &mut Vec<Recommendation>,
158) {
159 let DataValue::Definition {
160 base,
161 constraints,
162 value: _,
163 } = &data.value
164 else {
165 return;
166 };
167
168 let name = data.reference.name.clone();
169 let constraints = constraints.as_deref().unwrap_or(&[]);
170 let has_help = constraints
171 .iter()
172 .any(|(c, _)| matches!(c, TypeConstraintCommand::Help));
173 let has_option = constraints.iter().any(|(c, _)| {
174 matches!(
175 c,
176 TypeConstraintCommand::Option | TypeConstraintCommand::Options
177 )
178 });
179 let has_minimum = constraints
180 .iter()
181 .any(|(c, _)| matches!(c, TypeConstraintCommand::Minimum));
182 let has_maximum = constraints
183 .iter()
184 .any(|(c, _)| matches!(c, TypeConstraintCommand::Maximum));
185
186 if !has_help {
187 out.push(Recommendation {
188 message: format!(
189 "`{name}` has no `-> help`. Consider adding a message to help users understand this data."
190 ),
191 repository: repository.clone(),
192 spec: spec.name.clone(),
193 effective_from: spec.effective_from.to_option(),
194 source_location: data.source_location.clone(),
195 });
196 }
197
198 if is_primitive_bounded_quantity(base.as_ref()) && (!has_minimum || !has_maximum) {
199 let gap = match (has_minimum, has_maximum) {
200 (false, false) => "no `-> minimum` or `-> maximum`",
201 (true, false) => "no `-> maximum`",
202 (false, true) => "no `-> minimum`",
203 (true, true) => unreachable!("BUG: both bounds present but entered missing-bounds arm"),
204 };
205 out.push(Recommendation {
206 message: format!(
207 "`{name}` has {gap}. Consider adding bounds so out-of-range values are rejected."
208 ),
209 repository: repository.clone(),
210 spec: spec.name.clone(),
211 effective_from: spec.effective_from.to_option(),
212 source_location: data.source_location.clone(),
213 });
214 }
215
216 if is_primitive_text(base.as_ref()) && !has_option {
217 out.push(Recommendation {
218 message: format!(
219 "`{name}` accepts any text. Adding `-> option` values allows forms and APIs to offer choices."
220 ),
221 repository,
222 spec: spec.name.clone(),
223 effective_from: spec.effective_from.to_option(),
224 source_location: data.source_location.clone(),
225 });
226 }
227}
228
229fn unwrap_parent_type(base: Option<&ParentType>) -> Option<&ParentType> {
230 match base {
231 Some(ParentType::Qualified { inner, .. } | ParentType::Ranged { inner }) => {
232 unwrap_parent_type(Some(inner.as_ref()))
233 }
234 other => other,
235 }
236}
237
238fn is_primitive_text(base: Option<&ParentType>) -> bool {
239 matches!(
240 unwrap_parent_type(base),
241 Some(ParentType::Primitive {
242 primitive: PrimitiveKind::Text
243 })
244 )
245}
246
247fn is_primitive_bounded_quantity(base: Option<&ParentType>) -> bool {
248 matches!(
249 unwrap_parent_type(base),
250 Some(ParentType::Primitive {
251 primitive: PrimitiveKind::Number | PrimitiveKind::Measure | PrimitiveKind::Ratio
252 })
253 )
254}
255
256fn analyze_rule(
257 repository: Option<String>,
258 spec: &LemmaSpec,
259 rule: &LemmaRule,
260 out: &mut Vec<Recommendation>,
261) {
262 if !is_boolean_literal(&rule.expression) {
263 return;
264 }
265 if rule.unless_clauses.is_empty() {
266 return;
267 }
268 let all_veto = rule
269 .unless_clauses
270 .iter()
271 .all(|u| matches!(u.result.kind, ExpressionKind::Veto(_)));
272 if !all_veto {
273 return;
274 }
275 out.push(Recommendation {
276 message: format!(
277 "`{}` treats a yes/no outcome as veto. Consider `false` or `no` when denying — veto means there is no answer, and it blocks every rule that depends on this one.",
278 rule.name
279 ),
280 repository,
281 spec: spec.name.clone(),
282 effective_from: spec.effective_from.to_option(),
283 source_location: rule.source_location.clone(),
284 });
285}
286
287fn is_boolean_literal(expr: &Expression) -> bool {
288 matches!(&expr.kind, ExpressionKind::Literal(Value::Boolean(_)))
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use crate::parsing::source::SourceType;
295
296 fn load(code: &str) -> Engine {
297 let mut engine = Engine::new();
298 engine
299 .load([(
300 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
301 code.to_string(),
302 )])
303 .expect("BUG: test source must load");
304 engine
305 }
306
307 fn effective_display(r: &Recommendation) -> Option<String> {
308 r.effective_from.as_ref().map(|d| d.to_string())
309 }
310
311 #[test]
312 fn origin_without_commentary_does_not_flag_optional_gaps() {
313 let engine = load("spec pricing\ndata x: number\nrule y: x\n");
314 let recs = engine.quality();
315 assert!(
316 recs.iter().all(|r| {
317 !r.message.contains("commentary")
318 && !r.message.contains("effective date")
319 && !r.message.contains("-> suggest")
320 }),
321 "optional gaps must not be recommended: {recs:?}"
322 );
323 let help = recs
324 .iter()
325 .find(|r| r.message.contains("no `-> help`") && r.message.contains("x"))
326 .expect("missing help");
327 assert!(
328 help.message.contains("Consider adding a message"),
329 "got: {}",
330 help.message
331 );
332 assert_eq!(help.spec, "pricing");
333 assert_eq!(help.effective_from, None);
334 }
335
336 #[test]
337 fn clean_spec_has_no_recommendations() {
338 let engine = load(
339 r#"spec pricing 2026-01-01
340"""
341Bulk pricing.
342"""
343
344data qty: number
345 -> minimum 0
346 -> maximum 1000000
347 -> help "Order quantity."
348
349rule total: qty
350"#,
351 );
352 assert!(engine.quality().is_empty(), "got: {:?}", engine.quality());
353 }
354
355 #[test]
356 fn number_without_bounds() {
357 let engine = load(
358 r#"spec pricing 2026-01-01
359"""
360x
361"""
362
363data qty: number
364 -> help "Order quantity."
365rule total: qty
366"#,
367 );
368 let hit = engine
369 .quality()
370 .into_iter()
371 .find(|r| {
372 r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("qty")
373 })
374 .expect("missing bounds");
375 assert!(
376 hit.message.contains("Consider adding bounds"),
377 "got: {}",
378 hit.message
379 );
380 }
381
382 #[test]
383 fn number_with_only_minimum_still_flagged() {
384 let engine = load(
385 r#"spec pricing 2026-01-01
386"""
387x
388"""
389
390data qty: number
391 -> minimum 0
392 -> help "Order quantity."
393rule total: qty
394"#,
395 );
396 assert!(
397 engine.quality().iter().any(|r| {
398 r.message.contains("no `-> maximum`")
399 && !r.message.contains("no `-> minimum` or")
400 && r.message.contains("qty")
401 }),
402 "only minimum must flag missing maximum: {:?}",
403 engine.quality()
404 );
405 }
406
407 #[test]
408 fn number_with_only_maximum_still_flagged() {
409 let engine = load(
410 r#"spec pricing 2026-01-01
411"""
412x
413"""
414
415data qty: number
416 -> maximum 100
417 -> help "Order quantity."
418rule total: qty
419"#,
420 );
421 assert!(
422 engine.quality().iter().any(|r| {
423 r.message.contains("no `-> minimum`")
424 && !r.message.contains("or `-> maximum`")
425 && r.message.contains("qty")
426 }),
427 "only maximum must flag missing minimum: {:?}",
428 engine.quality()
429 );
430 }
431
432 #[test]
433 fn number_with_min_and_max_clean() {
434 let engine = load(
435 r#"spec pricing 2026-01-01
436"""
437x
438"""
439
440data qty: number
441 -> minimum 0
442 -> maximum 100
443 -> help "Order quantity."
444rule total: qty
445"#,
446 );
447 assert!(
448 !engine
449 .quality()
450 .iter()
451 .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
452 "min+max must not flag bounds: {:?}",
453 engine.quality()
454 );
455 }
456
457 #[test]
458 fn measure_and_ratio_without_bounds() {
459 let engine = load(
460 r#"spec pricing 2026-01-01
461"""
462x
463"""
464
465data price: measure
466 -> unit eur 1
467 -> help "Unit price."
468data discount: ratio
469 -> help "Discount rate."
470rule total: price
471rule rate: discount
472"#,
473 );
474 let recs = engine.quality();
475 assert!(
476 recs.iter().any(|r| {
477 r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("price")
478 }),
479 "measure must flag: {recs:?}"
480 );
481 assert!(
482 recs.iter().any(|r| {
483 r.message.contains("no `-> minimum` or `-> maximum`")
484 && r.message.contains("discount")
485 }),
486 "ratio must flag: {recs:?}"
487 );
488 }
489
490 #[test]
491 fn text_not_flagged_for_bounds() {
492 let engine = load(
493 r#"spec pricing 2026-01-01
494"""
495x
496"""
497
498data status: text
499 -> help "Status."
500rule ok: status is "active"
501"#,
502 );
503 assert!(
504 !engine
505 .quality()
506 .iter()
507 .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
508 "text must not get bounds rec: {:?}",
509 engine.quality()
510 );
511 }
512
513 #[test]
514 fn data_missing_help() {
515 let engine = load(
516 r#"spec pricing 2026-01-01
517"""
518x
519"""
520
521data qty: number
522rule total: qty
523"#,
524 );
525 let hit = engine
526 .quality()
527 .into_iter()
528 .find(|r| r.message.contains("no `-> help`") && r.message.contains("qty"))
529 .expect("missing help");
530 assert!(
531 hit.message.contains("Consider adding a message"),
532 "got: {}",
533 hit.message
534 );
535 assert_eq!(hit.spec, "pricing");
536 assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
537 assert!(
538 hit.to_string().starts_with(
539 "In spec 'pricing' (effective from 2026-01-01): `qty` has no `-> help`"
540 ),
541 "Display must include effective_from, got: {}",
542 hit
543 );
544 assert!(
545 hit.to_string().contains(" at test.lemma:"),
546 "Display must append source location, got: {}",
547 hit
548 );
549 }
550
551 #[test]
552 fn text_without_options() {
553 let engine = load(
554 r#"spec pricing 2026-01-01
555"""
556x
557"""
558
559data status: text
560 -> help "Status."
561rule ok: status is "active"
562"#,
563 );
564 let hit = engine
565 .quality()
566 .into_iter()
567 .find(|r| r.message.contains("accepts any text") && r.message.contains("status"))
568 .expect("text without options");
569 assert!(
570 hit.message.contains("offer choices"),
571 "got: {}",
572 hit.message
573 );
574 assert_eq!(hit.spec, "pricing");
575 assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
576 }
577
578 #[test]
579 fn veto_as_rejection_cascade() {
580 let engine = load(
581 r#"spec eligibility 2026-01-01
582"""
583Age gate.
584"""
585
586data age: number
587 -> help "Customer age."
588
589rule is_eligible: true
590 unless age < 18 then veto "Must be 18+"
591"#,
592 );
593 let hit = engine
594 .quality()
595 .into_iter()
596 .find(|r| {
597 r.message.contains("treats a yes/no outcome as veto")
598 && r.message.contains("is_eligible")
599 })
600 .expect("veto cascade");
601 assert_eq!(hit.spec, "eligibility");
602 assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
603 }
604
605 #[test]
606 fn boolean_denial_is_not_cascade() {
607 let engine = load(
608 r#"spec eligibility 2026-01-01
609"""
610Age gate.
611"""
612
613data age: number
614 -> help "Customer age."
615
616rule is_eligible: true
617 unless age < 18 then false
618"#,
619 );
620 assert!(!engine
621 .quality()
622 .iter()
623 .any(|r| r.message.contains("treats a yes/no outcome as veto")));
624 }
625
626 #[test]
627 fn stdlib_dependency_not_reported() {
628 let engine = load(
629 r#"spec ship 2026-01-01
630"""
631Weight check.
632"""
633
634uses lemma units
635
636data package_weight: units.mass
637 -> help "Package weight."
638
639rule heavy: package_weight > 20 kilogram
640"#,
641 );
642 let recs = engine.quality();
643 assert!(
644 recs.iter().all(|r| r.spec != "units"),
645 "stdlib units must not appear: {recs:?}"
646 );
647 }
648
649 #[test]
650 fn temporal_slices_distinct_by_effective_from() {
651 let engine = load(
652 r#"spec pricing 1933-01-01
653"""
654Old.
655"""
656
657data qty: number
658rule total: qty
659
660spec pricing 2026-01-01
661"""
662New.
663"""
664
665data qty: number
666rule total: qty
667"#,
668 );
669 let helps: Vec<_> = engine
670 .quality()
671 .into_iter()
672 .filter(|r| r.message.contains("no `-> help`"))
673 .collect();
674 assert_eq!(helps.len(), 2, "got: {helps:?}");
675 assert!(
676 helps.iter().any(|r| {
677 r.spec == "pricing" && effective_display(r).as_deref() == Some("1933-01-01")
678 }),
679 "missing 1933 slice: {helps:?}"
680 );
681 assert!(
682 helps.iter().any(|r| {
683 r.spec == "pricing" && effective_display(r).as_deref() == Some("2026-01-01")
684 }),
685 "missing 2026 slice: {helps:?}"
686 );
687 assert!(
688 helps
689 .iter()
690 .all(|r| !r.message.contains("1933") && !r.message.contains("2026")),
691 "message must not carry temporal identity: {helps:?}"
692 );
693 let displays: Vec<String> = helps.iter().map(|r| r.to_string()).collect();
694 assert!(
695 displays
696 .iter()
697 .any(|s| s.contains("In spec 'pricing' (effective from 1933-01-01)")),
698 "Display missing 1933: {displays:?}"
699 );
700 assert!(
701 displays
702 .iter()
703 .any(|s| s.contains("In spec 'pricing' (effective from 2026-01-01)")),
704 "Display missing 2026: {displays:?}"
705 );
706 }
707
708 #[test]
709 fn recommendation_wire_json_uses_source_not_source_location() {
710 let engine = load(
711 r#"spec pricing 2026-01-01
712"""
713x
714"""
715
716data qty: number
717rule total: qty
718"#,
719 );
720 let hit = engine
721 .quality()
722 .into_iter()
723 .find(|r| r.message.contains("no `-> help`"))
724 .expect("missing help");
725 let json = serde_json::to_value(&hit).expect("serialize");
726 assert!(json.get("source").is_some(), "wire must use source: {json}");
727 assert!(
728 json.get("source_location").is_none(),
729 "wire must not expose source_location: {json}"
730 );
731 assert_eq!(json["source"]["attribute"], serde_json::json!("test.lemma"));
732 let round: Recommendation = serde_json::from_value(json).expect("deserialize");
733 assert_eq!(round.spec, "pricing");
734 assert!(round.message.contains("no `-> help`"));
735 }
736}