1use crate::error::EngineErrorSource;
7use crate::literals::{BooleanValue, Value};
8use crate::parsing::ast::{
9 ComparisonComputation, DataValue, Expression, ExpressionKind, LemmaData, LemmaRule, LemmaSpec,
10 NegationType, ParentType, 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_import(
154 repository: Option<String>,
155 spec: &LemmaSpec,
156 bindings: &[crate::parsing::ast::UsesBinding],
157 out: &mut Vec<Recommendation>,
158) {
159 for binding in bindings {
160 if !binding.deprecated_standalone_with {
161 continue;
162 }
163 out.push(Recommendation {
164 message: "Standalone `with alias.field: …` is deprecated; nest under the matching `uses` line as ` -> with field: …`.".to_string(),
165 repository: repository.clone(),
166 spec: spec.name.clone(),
167 effective_from: spec.effective_from.to_option(),
168 source_location: binding.source_location.clone(),
169 });
170 }
171}
172
173fn analyze_data(
174 repository: Option<String>,
175 spec: &LemmaSpec,
176 data: &LemmaData,
177 out: &mut Vec<Recommendation>,
178) {
179 if let DataValue::Import { bindings, .. } = &data.value {
180 analyze_import(repository.clone(), spec, bindings, out);
181 return;
182 }
183
184 let DataValue::Definition {
185 base,
186 constraints,
187 value: _,
188 } = &data.value
189 else {
190 return;
191 };
192
193 let name = data.reference.name.clone();
194 let constraints = constraints.as_deref().unwrap_or(&[]);
195 let has_help = constraints
196 .iter()
197 .any(|row| matches!(row.command, TypeConstraintCommand::Help));
198 let has_option = constraints.iter().any(|row| {
199 matches!(
200 row.command,
201 TypeConstraintCommand::Option | TypeConstraintCommand::Options
202 )
203 });
204 let has_minimum = constraints
205 .iter()
206 .any(|row| matches!(row.command, TypeConstraintCommand::Minimum));
207 let has_maximum = constraints
208 .iter()
209 .any(|row| matches!(row.command, TypeConstraintCommand::Maximum));
210
211 for row in constraints {
212 if !row.deprecated_without_colon {
213 continue;
214 }
215 out.push(Recommendation {
216 message: "`-> unit name value` is deprecated; use `-> unit name: value`.".to_string(),
217 repository: repository.clone(),
218 spec: spec.name.clone(),
219 effective_from: spec.effective_from.to_option(),
220 source_location: row.source_location.clone(),
221 });
222 }
223
224 if !has_help {
225 out.push(Recommendation {
226 message: format!(
227 "`{name}` has no `-> help`. Consider adding a message to help users understand this data."
228 ),
229 repository: repository.clone(),
230 spec: spec.name.clone(),
231 effective_from: spec.effective_from.to_option(),
232 source_location: data.source_location.clone(),
233 });
234 }
235
236 if is_primitive_bounded_quantity(base.as_ref()) && (!has_minimum || !has_maximum) {
237 let gap = match (has_minimum, has_maximum) {
238 (false, false) => "no `-> minimum` or `-> maximum`",
239 (true, false) => "no `-> maximum`",
240 (false, true) => "no `-> minimum`",
241 (true, true) => unreachable!("BUG: both bounds present but entered missing-bounds arm"),
242 };
243 out.push(Recommendation {
244 message: format!(
245 "`{name}` has {gap}. Consider adding bounds so out-of-range values are rejected."
246 ),
247 repository: repository.clone(),
248 spec: spec.name.clone(),
249 effective_from: spec.effective_from.to_option(),
250 source_location: data.source_location.clone(),
251 });
252 }
253
254 if is_primitive_text(base.as_ref()) && !has_option {
255 out.push(Recommendation {
256 message: format!(
257 "`{name}` accepts any text. Adding `-> option` values allows forms and APIs to offer choices."
258 ),
259 repository,
260 spec: spec.name.clone(),
261 effective_from: spec.effective_from.to_option(),
262 source_location: data.source_location.clone(),
263 });
264 }
265}
266
267fn unwrap_parent_type(base: Option<&ParentType>) -> Option<&ParentType> {
268 match base {
269 Some(ParentType::Qualified { inner, .. } | ParentType::Ranged { inner }) => {
270 unwrap_parent_type(Some(inner.as_ref()))
271 }
272 other => other,
273 }
274}
275
276fn is_primitive_text(base: Option<&ParentType>) -> bool {
277 matches!(
278 unwrap_parent_type(base),
279 Some(ParentType::Primitive {
280 primitive: PrimitiveKind::Text
281 })
282 )
283}
284
285fn is_primitive_bounded_quantity(base: Option<&ParentType>) -> bool {
286 matches!(
287 unwrap_parent_type(base),
288 Some(ParentType::Primitive {
289 primitive: PrimitiveKind::Number | PrimitiveKind::Measure | PrimitiveKind::Ratio
290 })
291 )
292}
293
294fn analyze_rule(
295 repository: Option<String>,
296 spec: &LemmaSpec,
297 rule: &LemmaRule,
298 out: &mut Vec<Recommendation>,
299) {
300 walk_expr_for_ambiguous_and(repository.clone(), spec, rule, &rule.expression, out);
301 for unless in &rule.unless_clauses {
302 walk_expr_for_ambiguous_and(repository.clone(), spec, rule, &unless.condition, out);
303 walk_expr_for_ambiguous_and(repository.clone(), spec, rule, &unless.result, out);
304 }
305
306 analyze_redundant_boolean_default_unless(repository.clone(), spec, rule, out);
307
308 if is_boolean_literal(&rule.expression)
309 && !rule.unless_clauses.is_empty()
310 && rule
311 .unless_clauses
312 .iter()
313 .all(|u| matches!(u.result.kind, ExpressionKind::Veto(_)))
314 {
315 out.push(Recommendation {
316 message: format!(
317 "`{}` 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.",
318 rule.name
319 ),
320 repository,
321 spec: spec.name.clone(),
322 effective_from: spec.effective_from.to_option(),
323 source_location: rule.source_location.clone(),
324 });
325 }
326}
327
328fn boolean_literal_truth(expr: &Expression) -> Option<bool> {
329 match &expr.kind {
330 ExpressionKind::Literal(Value::Boolean(b)) => Some(bool::from(*b)),
331 _ => None,
332 }
333}
334
335fn analyze_redundant_boolean_default_unless(
336 repository: Option<String>,
337 spec: &LemmaSpec,
338 rule: &LemmaRule,
339 out: &mut Vec<Recommendation>,
340) {
341 if rule.unless_clauses.len() != 1 {
342 return;
343 }
344 let unless = &rule.unless_clauses[0];
345 if boolean_literal_truth(&rule.expression) != Some(false) {
346 return;
347 }
348 if boolean_literal_truth(&unless.result) != Some(true) {
349 return;
350 }
351 let condition = format!("{}", unless.condition);
352 out.push(Recommendation {
353 message: format!(
354 "`{}` uses `no` (or `false`) with a single `unless … then yes`. Equivalent to `{condition}`; use a direct expression instead.",
355 rule.name
356 ),
357 repository,
358 spec: spec.name.clone(),
359 effective_from: spec.effective_from.to_option(),
360 source_location: rule.source_location.clone(),
361 });
362}
363
364fn is_boolean_literal(expr: &Expression) -> bool {
365 boolean_literal_truth(expr).is_some()
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370enum BoolConjunct {
371 Bare,
372 NotRef,
373 IsFalse,
374 Other,
375}
376
377fn classify_bool_conjunct(expr: &Expression) -> BoolConjunct {
378 match &expr.kind {
379 ExpressionKind::Reference(_) => BoolConjunct::Bare,
380 ExpressionKind::LogicalNegation(inner, NegationType::Not)
381 if matches!(inner.kind, ExpressionKind::Reference(_)) =>
382 {
383 BoolConjunct::NotRef
384 }
385 ExpressionKind::Comparison(left, ComparisonComputation::Is, right)
386 if matches!(left.kind, ExpressionKind::Reference(_))
387 && matches!(
388 right.kind,
389 ExpressionKind::Literal(Value::Boolean(BooleanValue::False | BooleanValue::No))
390 ) =>
391 {
392 BoolConjunct::IsFalse
393 }
394 _ => BoolConjunct::Other,
395 }
396}
397
398fn is_ambiguous_logical_and(left: &Expression, right: &Expression) -> bool {
399 matches!(
400 (classify_bool_conjunct(left), classify_bool_conjunct(right)),
401 (BoolConjunct::Bare, BoolConjunct::NotRef)
402 | (BoolConjunct::NotRef, BoolConjunct::Bare)
403 | (BoolConjunct::Bare, BoolConjunct::IsFalse)
404 | (BoolConjunct::IsFalse, BoolConjunct::Bare)
405 )
406}
407
408const AMBIGUOUS_AND_MESSAGE: &str = "Boolean `and` mixes a bare name with `not` / `is false`. Readers misread scope. Prefer parallel probes (`not x and y is true`, or `x is false and y is true`) or parentheses.";
409
410fn walk_expr_for_ambiguous_and(
411 repository: Option<String>,
412 spec: &LemmaSpec,
413 rule: &LemmaRule,
414 expr: &Expression,
415 out: &mut Vec<Recommendation>,
416) {
417 match &expr.kind {
418 ExpressionKind::LogicalAnd(left, right) => {
419 if is_ambiguous_logical_and(left, right) {
420 out.push(Recommendation {
421 message: AMBIGUOUS_AND_MESSAGE.to_string(),
422 repository: repository.clone(),
423 spec: spec.name.clone(),
424 effective_from: spec.effective_from.to_option(),
425 source_location: expr
426 .source_location
427 .clone()
428 .unwrap_or_else(|| rule.source_location.clone()),
429 });
430 }
431 walk_expr_for_ambiguous_and(repository.clone(), spec, rule, left, out);
432 walk_expr_for_ambiguous_and(repository, spec, rule, right, out);
433 }
434 ExpressionKind::DateRelative(_, inner)
435 | ExpressionKind::PastFutureRange(_, inner)
436 | ExpressionKind::UnitConversion(inner, _)
437 | ExpressionKind::LogicalNegation(inner, _)
438 | ExpressionKind::MathematicalComputation(_, inner)
439 | ExpressionKind::RangeBound(_, inner)
440 | ExpressionKind::ResultIsVeto(inner) => {
441 walk_expr_for_ambiguous_and(repository, spec, rule, inner, out);
442 }
443 ExpressionKind::DateCalendar(_, _, inner) => {
444 walk_expr_for_ambiguous_and(repository, spec, rule, inner, out);
445 }
446 ExpressionKind::RangeLiteral(left, right)
447 | ExpressionKind::RangeContainment(left, right)
448 | ExpressionKind::Arithmetic(left, _, right)
449 | ExpressionKind::Comparison(left, _, right) => {
450 walk_expr_for_ambiguous_and(repository.clone(), spec, rule, left, out);
451 walk_expr_for_ambiguous_and(repository, spec, rule, right, out);
452 }
453 ExpressionKind::Literal(_)
454 | ExpressionKind::Reference(_)
455 | ExpressionKind::Now
456 | ExpressionKind::Veto(_) => {}
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use crate::parsing::source::SourceType;
464
465 fn load(code: &str) -> Engine {
466 let mut engine = Engine::new();
467 engine
468 .load([(
469 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
470 code.to_string(),
471 )])
472 .expect("BUG: test source must load");
473 engine
474 }
475
476 fn effective_display(r: &Recommendation) -> Option<String> {
477 r.effective_from.as_ref().map(|d| d.to_string())
478 }
479
480 #[test]
481 fn origin_without_commentary_does_not_flag_optional_gaps() {
482 let engine = load("spec pricing\ndata x: number\nrule y: x\n");
483 let recs = engine.quality();
484 assert!(
485 recs.iter().all(|r| {
486 !r.message.contains("commentary")
487 && !r.message.contains("effective date")
488 && !r.message.contains("-> suggest")
489 }),
490 "optional gaps must not be recommended: {recs:?}"
491 );
492 let help = recs
493 .iter()
494 .find(|r| r.message.contains("no `-> help`") && r.message.contains("x"))
495 .expect("missing help");
496 assert!(
497 help.message.contains("Consider adding a message"),
498 "got: {}",
499 help.message
500 );
501 assert_eq!(help.spec, "pricing");
502 assert_eq!(help.effective_from, None);
503 }
504
505 #[test]
506 fn clean_spec_has_no_recommendations() {
507 let engine = load(
508 r#"spec pricing 2026-01-01
509"""
510Bulk pricing.
511"""
512
513data qty: number
514 -> minimum 0
515 -> maximum 1000000
516 -> help "Order quantity."
517
518rule total: qty
519"#,
520 );
521 assert!(engine.quality().is_empty(), "got: {:?}", engine.quality());
522 }
523
524 #[test]
525 fn deprecated_standalone_with_emits_quality_recommendation() {
526 let engine = load(
527 r#"spec inner
528data x: number
529
530spec outer
531uses i: inner
532with i.x: 42
533rule r: i.x
534"#,
535 );
536 let recs = engine.quality();
537 let hit = recs
538 .iter()
539 .find(|r| r.message.contains("deprecated") && r.spec == "outer")
540 .expect("deprecated standalone with must produce quality recommendation");
541 assert!(hit.message.contains("-> with"), "got: {}", hit.message);
542 assert_eq!(hit.source_location.span.line, 6);
543 }
544
545 #[test]
546 fn block_uses_binding_has_no_deprecated_recommendation() {
547 let engine = load(
548 r#"spec inner
549data x: number
550
551spec outer
552uses i: inner
553 -> with x: 42
554rule r: i.x
555"#,
556 );
557 assert!(
558 engine
559 .quality()
560 .iter()
561 .all(|r| !r.message.contains("deprecated")),
562 "block syntax must not flag deprecated: {:?}",
563 engine.quality()
564 );
565 }
566
567 #[test]
568 fn number_without_bounds() {
569 let engine = load(
570 r#"spec pricing 2026-01-01
571"""
572x
573"""
574
575data qty: number
576 -> help "Order quantity."
577rule total: qty
578"#,
579 );
580 let hit = engine
581 .quality()
582 .into_iter()
583 .find(|r| {
584 r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("qty")
585 })
586 .expect("missing bounds");
587 assert!(
588 hit.message.contains("Consider adding bounds"),
589 "got: {}",
590 hit.message
591 );
592 }
593
594 #[test]
595 fn number_with_only_minimum_still_flagged() {
596 let engine = load(
597 r#"spec pricing 2026-01-01
598"""
599x
600"""
601
602data qty: number
603 -> minimum 0
604 -> help "Order quantity."
605rule total: qty
606"#,
607 );
608 assert!(
609 engine.quality().iter().any(|r| {
610 r.message.contains("no `-> maximum`")
611 && !r.message.contains("no `-> minimum` or")
612 && r.message.contains("qty")
613 }),
614 "only minimum must flag missing maximum: {:?}",
615 engine.quality()
616 );
617 }
618
619 #[test]
620 fn number_with_only_maximum_still_flagged() {
621 let engine = load(
622 r#"spec pricing 2026-01-01
623"""
624x
625"""
626
627data qty: number
628 -> maximum 100
629 -> help "Order quantity."
630rule total: qty
631"#,
632 );
633 assert!(
634 engine.quality().iter().any(|r| {
635 r.message.contains("no `-> minimum`")
636 && !r.message.contains("or `-> maximum`")
637 && r.message.contains("qty")
638 }),
639 "only maximum must flag missing minimum: {:?}",
640 engine.quality()
641 );
642 }
643
644 #[test]
645 fn number_with_min_and_max_clean() {
646 let engine = load(
647 r#"spec pricing 2026-01-01
648"""
649x
650"""
651
652data qty: number
653 -> minimum 0
654 -> maximum 100
655 -> help "Order quantity."
656rule total: qty
657"#,
658 );
659 assert!(
660 !engine
661 .quality()
662 .iter()
663 .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
664 "min+max must not flag bounds: {:?}",
665 engine.quality()
666 );
667 }
668
669 #[test]
670 fn measure_and_ratio_without_bounds() {
671 let engine = load(
672 r#"spec pricing 2026-01-01
673"""
674x
675"""
676
677data price: measure
678 -> unit eur: 1
679 -> help "Unit price."
680data discount: ratio
681 -> help "Discount rate."
682rule total: price
683rule rate: discount
684"#,
685 );
686 let recs = engine.quality();
687 assert!(
688 recs.iter().any(|r| {
689 r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("price")
690 }),
691 "measure must flag: {recs:?}"
692 );
693 assert!(
694 recs.iter().any(|r| {
695 r.message.contains("no `-> minimum` or `-> maximum`")
696 && r.message.contains("discount")
697 }),
698 "ratio must flag: {recs:?}"
699 );
700 }
701
702 #[test]
703 fn text_not_flagged_for_bounds() {
704 let engine = load(
705 r#"spec pricing 2026-01-01
706"""
707x
708"""
709
710data status: text
711 -> help "Status."
712rule ok: status is "active"
713"#,
714 );
715 assert!(
716 !engine
717 .quality()
718 .iter()
719 .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
720 "text must not get bounds rec: {:?}",
721 engine.quality()
722 );
723 }
724
725 #[test]
726 fn data_missing_help() {
727 let engine = load(
728 r#"spec pricing 2026-01-01
729"""
730x
731"""
732
733data qty: number
734rule total: qty
735"#,
736 );
737 let hit = engine
738 .quality()
739 .into_iter()
740 .find(|r| r.message.contains("no `-> help`") && r.message.contains("qty"))
741 .expect("missing help");
742 assert!(
743 hit.message.contains("Consider adding a message"),
744 "got: {}",
745 hit.message
746 );
747 assert_eq!(hit.spec, "pricing");
748 assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
749 assert!(
750 hit.to_string().starts_with(
751 "In spec 'pricing' (effective from 2026-01-01): `qty` has no `-> help`"
752 ),
753 "Display must include effective_from, got: {}",
754 hit
755 );
756 assert!(
757 hit.to_string().contains(" at test.lemma:"),
758 "Display must append source location, got: {}",
759 hit
760 );
761 }
762
763 #[test]
764 fn text_without_options() {
765 let engine = load(
766 r#"spec pricing 2026-01-01
767"""
768x
769"""
770
771data status: text
772 -> help "Status."
773rule ok: status is "active"
774"#,
775 );
776 let hit = engine
777 .quality()
778 .into_iter()
779 .find(|r| r.message.contains("accepts any text") && r.message.contains("status"))
780 .expect("text without options");
781 assert!(
782 hit.message.contains("offer choices"),
783 "got: {}",
784 hit.message
785 );
786 assert_eq!(hit.spec, "pricing");
787 assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
788 }
789
790 #[test]
791 fn veto_as_rejection_cascade() {
792 let engine = load(
793 r#"spec eligibility 2026-01-01
794"""
795Age gate.
796"""
797
798data age: number
799 -> help "Customer age."
800
801rule is_eligible: true
802 unless age < 18 then veto "Must be 18+"
803"#,
804 );
805 let hit = engine
806 .quality()
807 .into_iter()
808 .find(|r| {
809 r.message.contains("treats a yes/no outcome as veto")
810 && r.message.contains("is_eligible")
811 })
812 .expect("veto cascade");
813 assert_eq!(hit.spec, "eligibility");
814 assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
815 }
816
817 #[test]
818 fn boolean_denial_is_not_cascade() {
819 let engine = load(
820 r#"spec eligibility 2026-01-01
821"""
822Age gate.
823"""
824
825data age: number
826 -> help "Customer age."
827
828rule is_eligible: true
829 unless age < 18 then false
830"#,
831 );
832 assert!(!engine
833 .quality()
834 .iter()
835 .any(|r| r.message.contains("treats a yes/no outcome as veto")));
836 }
837
838 #[test]
839 fn stdlib_dependency_not_reported() {
840 let engine = load(
841 r#"spec ship 2026-01-01
842"""
843Weight check.
844"""
845
846uses lemma units
847
848data package_weight: units.mass
849 -> help "Package weight."
850
851rule heavy: package_weight > 20 kilogram
852"#,
853 );
854 let recs = engine.quality();
855 assert!(
856 recs.iter().all(|r| r.spec != "units"),
857 "stdlib units must not appear: {recs:?}"
858 );
859 }
860
861 #[test]
862 fn temporal_slices_distinct_by_effective_from() {
863 let engine = load(
864 r#"spec pricing 1933-01-01
865"""
866Old.
867"""
868
869data qty: number
870rule total: qty
871
872spec pricing 2026-01-01
873"""
874New.
875"""
876
877data qty: number
878rule total: qty
879"#,
880 );
881 let helps: Vec<_> = engine
882 .quality()
883 .into_iter()
884 .filter(|r| r.message.contains("no `-> help`"))
885 .collect();
886 assert_eq!(helps.len(), 2, "got: {helps:?}");
887 assert!(
888 helps.iter().any(|r| {
889 r.spec == "pricing" && effective_display(r).as_deref() == Some("1933-01-01")
890 }),
891 "missing 1933 slice: {helps:?}"
892 );
893 assert!(
894 helps.iter().any(|r| {
895 r.spec == "pricing" && effective_display(r).as_deref() == Some("2026-01-01")
896 }),
897 "missing 2026 slice: {helps:?}"
898 );
899 assert!(
900 helps
901 .iter()
902 .all(|r| !r.message.contains("1933") && !r.message.contains("2026")),
903 "message must not carry temporal identity: {helps:?}"
904 );
905 let displays: Vec<String> = helps.iter().map(|r| r.to_string()).collect();
906 assert!(
907 displays
908 .iter()
909 .any(|s| s.contains("In spec 'pricing' (effective from 1933-01-01)")),
910 "Display missing 1933: {displays:?}"
911 );
912 assert!(
913 displays
914 .iter()
915 .any(|s| s.contains("In spec 'pricing' (effective from 2026-01-01)")),
916 "Display missing 2026: {displays:?}"
917 );
918 }
919
920 #[test]
921 fn recommendation_wire_json_uses_source_not_source_location() {
922 let engine = load(
923 r#"spec pricing 2026-01-01
924"""
925x
926"""
927
928data qty: number
929rule total: qty
930"#,
931 );
932 let hit = engine
933 .quality()
934 .into_iter()
935 .find(|r| r.message.contains("no `-> help`"))
936 .expect("missing help");
937 let json = serde_json::to_value(&hit).expect("serialize");
938 assert!(json.get("source").is_some(), "wire must use source: {json}");
939 assert!(
940 json.get("source_location").is_none(),
941 "wire must not expose source_location: {json}"
942 );
943 assert_eq!(json["source"]["attribute"], serde_json::json!("test.lemma"));
944 let round: Recommendation = serde_json::from_value(json).expect("deserialize");
945 assert_eq!(round.spec, "pricing");
946 assert!(round.message.contains("no `-> help`"));
947 }
948
949 fn and_chain_spec(rule_body: &str) -> String {
950 format!(
951 r#"spec gate 2026-01-01
952"""
953Gate.
954"""
955
956data ready: boolean
957 -> help "Ready?"
958data eligible: boolean
959 -> help "Eligible?"
960
961rule pass: no
962 {rule_body}
963"#
964 )
965 }
966
967 fn ambiguous_and_hits(engine: &Engine) -> Vec<Recommendation> {
968 engine
969 .quality()
970 .into_iter()
971 .filter(|r| r.message.contains("Boolean `and` mixes a bare name"))
972 .collect()
973 }
974
975 #[test]
976 fn flags_not_ref_and_bare() {
977 let engine = load(&and_chain_spec("unless not ready and eligible then yes"));
978 let hits = ambiguous_and_hits(&engine);
979 assert_eq!(hits.len(), 1, "got: {hits:?}");
980 assert_eq!(hits[0].spec, "gate");
981 assert!(
982 hits[0].message.contains("Prefer parallel probes"),
983 "got: {}",
984 hits[0].message
985 );
986 }
987
988 #[test]
989 fn flags_bare_and_is_false() {
990 let engine = load(&and_chain_spec(
991 "unless ready and eligible is false then yes",
992 ));
993 let hits = ambiguous_and_hits(&engine);
994 assert_eq!(hits.len(), 1, "got: {hits:?}");
995 assert_eq!(hits[0].spec, "gate");
996 }
997
998 #[test]
999 fn clean_not_and_is_true() {
1000 let engine = load(&and_chain_spec(
1001 "unless not ready and eligible is true then yes",
1002 ));
1003 assert!(
1004 ambiguous_and_hits(&engine).is_empty(),
1005 "got: {:?}",
1006 ambiguous_and_hits(&engine)
1007 );
1008 }
1009
1010 #[test]
1011 fn clean_is_false_and_is_true() {
1012 let engine = load(&and_chain_spec(
1013 "unless ready is false and eligible is true then yes",
1014 ));
1015 assert!(
1016 ambiguous_and_hits(&engine).is_empty(),
1017 "got: {:?}",
1018 ambiguous_and_hits(&engine)
1019 );
1020 }
1021
1022 #[test]
1023 fn clean_unary_not() {
1024 let engine = load(&and_chain_spec("unless not ready then yes"));
1025 assert!(
1026 ambiguous_and_hits(&engine).is_empty(),
1027 "got: {:?}",
1028 ambiguous_and_hits(&engine)
1029 );
1030 }
1031
1032 #[test]
1033 fn clean_bare_and_bare() {
1034 let engine = load(&and_chain_spec("unless ready and eligible then yes"));
1035 assert!(
1036 ambiguous_and_hits(&engine).is_empty(),
1037 "got: {:?}",
1038 ambiguous_and_hits(&engine)
1039 );
1040 }
1041
1042 fn redundant_unless_hits(engine: &Engine) -> Vec<Recommendation> {
1043 engine
1044 .quality()
1045 .into_iter()
1046 .filter(|r| r.message.contains("single `unless … then yes`"))
1047 .collect()
1048 }
1049
1050 #[test]
1051 fn flags_redundant_no_unless_yes() {
1052 let engine = load(&and_chain_spec("unless ready and eligible then yes"));
1053 let hits = redundant_unless_hits(&engine);
1054 assert_eq!(hits.len(), 1, "got: {hits:?}");
1055 assert!(
1056 hits[0].message.contains("ready and eligible"),
1057 "{}",
1058 hits[0].message
1059 );
1060 }
1061
1062 #[test]
1063 fn flags_redundant_false_unless_true() {
1064 let engine = load(
1065 r#"spec gate
1066data ready: boolean
1067
1068rule pass: false
1069 unless ready then true
1070"#,
1071 );
1072 let hits = redundant_unless_hits(&engine);
1073 assert_eq!(hits.len(), 1, "got: {hits:?}");
1074 }
1075
1076 #[test]
1077 fn flags_redundant_no_unless_not_ready_then_yes() {
1078 let engine = load(&and_chain_spec("unless not ready then yes"));
1079 let hits = redundant_unless_hits(&engine);
1080 assert_eq!(hits.len(), 1, "got: {hits:?}");
1081 assert!(hits[0].message.contains("not ready"), "{}", hits[0].message);
1082 }
1083
1084 #[test]
1085 fn clean_symmetric_yes_unless_no() {
1086 let engine = load(
1087 r#"spec gate
1088data ready: boolean
1089data eligible: boolean
1090
1091rule pass: yes
1092 unless ready and eligible then no
1093"#,
1094 );
1095 assert!(
1096 redundant_unless_hits(&engine).is_empty(),
1097 "got: {:?}",
1098 redundant_unless_hits(&engine)
1099 );
1100 }
1101
1102 #[test]
1103 fn clean_multi_unless_discount() {
1104 let engine = load(
1105 r#"spec vip_discount
1106data qty: number
1107 -> minimum 0
1108data is_vip: boolean
1109
1110rule discount: 0%
1111 unless qty >= 10 then 10%
1112 unless qty >= 50 then 20%
1113 unless is_vip then 25%
1114"#,
1115 );
1116 assert!(
1117 redundant_unless_hits(&engine).is_empty(),
1118 "got: {:?}",
1119 redundant_unless_hits(&engine)
1120 );
1121 }
1122
1123 #[test]
1124 fn clean_multi_unless_needs_jacket() {
1125 let engine = load(
1126 r#"spec weather
1127uses lemma units
1128
1129data temperature: measure
1130 -> unit celsius: 1.0
1131data is_raining: boolean
1132data wind_speed: number
1133
1134rule needs_jacket: no
1135 unless temperature < 15 celsius then yes
1136 unless is_raining then yes
1137 unless wind_speed > 20 then yes
1138"#,
1139 );
1140 assert!(
1141 redundant_unless_hits(&engine).is_empty(),
1142 "got: {:?}",
1143 redundant_unless_hits(&engine)
1144 );
1145 }
1146
1147 #[test]
1148 fn clean_direct_boolean_rule() {
1149 let engine = load(
1150 r#"spec gate
1151data ready: boolean
1152data eligible: boolean
1153
1154rule pass: ready and eligible
1155"#,
1156 );
1157 assert!(
1158 redundant_unless_hits(&engine).is_empty(),
1159 "got: {:?}",
1160 redundant_unless_hits(&engine)
1161 );
1162 }
1163
1164 #[test]
1165 fn clean_non_boolean_unless() {
1166 let engine = load(
1167 r#"spec pricing
1168data qty: number
1169
1170rule discount: 0%
1171 unless qty >= 10 then 10%
1172"#,
1173 );
1174 assert!(
1175 redundant_unless_hits(&engine).is_empty(),
1176 "got: {:?}",
1177 redundant_unless_hits(&engine)
1178 );
1179 }
1180
1181 #[test]
1182 fn deprecated_unit_without_colon_emits_quality_recommendation() {
1183 let engine = load(
1184 r#"spec money_spec
1185data money: measure
1186 -> unit eur 1.00
1187 -> help "Money amount."
1188"#,
1189 );
1190 let hit = engine
1191 .quality()
1192 .into_iter()
1193 .find(|r| r.message.contains("deprecated") && r.spec == "money_spec")
1194 .expect("deprecated unit without colon must produce quality recommendation");
1195 assert!(
1196 hit.message.contains("unit") && hit.message.contains(":"),
1197 "got: {}",
1198 hit.message
1199 );
1200 }
1201
1202 #[test]
1203 fn unit_with_colon_has_no_deprecated_unit_recommendation() {
1204 let engine = load(
1205 r#"spec money_spec
1206data money: measure
1207 -> unit eur: 1.00
1208 -> help "Money amount."
1209"#,
1210 );
1211 assert!(
1212 engine
1213 .quality()
1214 .iter()
1215 .all(|r| !r.message.contains("deprecated")),
1216 "colon unit syntax must not flag deprecated: {:?}",
1217 engine.quality()
1218 );
1219 }
1220}