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::ResultIsVeto(inner) => {
440 walk_expr_for_ambiguous_and(repository, spec, rule, inner, out);
441 }
442 ExpressionKind::DateCalendar(_, _, inner) => {
443 walk_expr_for_ambiguous_and(repository, spec, rule, inner, out);
444 }
445 ExpressionKind::RangeLiteral(left, right)
446 | ExpressionKind::RangeContainment(left, right)
447 | ExpressionKind::Arithmetic(left, _, right)
448 | ExpressionKind::Comparison(left, _, right) => {
449 walk_expr_for_ambiguous_and(repository.clone(), spec, rule, left, out);
450 walk_expr_for_ambiguous_and(repository, spec, rule, right, out);
451 }
452 ExpressionKind::Literal(_)
453 | ExpressionKind::Reference(_)
454 | ExpressionKind::Now
455 | ExpressionKind::Veto(_) => {}
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use crate::parsing::source::SourceType;
463
464 fn load(code: &str) -> Engine {
465 let mut engine = Engine::new();
466 engine
467 .load([(
468 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
469 code.to_string(),
470 )])
471 .expect("BUG: test source must load");
472 engine
473 }
474
475 fn effective_display(r: &Recommendation) -> Option<String> {
476 r.effective_from.as_ref().map(|d| d.to_string())
477 }
478
479 #[test]
480 fn origin_without_commentary_does_not_flag_optional_gaps() {
481 let engine = load("spec pricing\ndata x: number\nrule y: x\n");
482 let recs = engine.quality();
483 assert!(
484 recs.iter().all(|r| {
485 !r.message.contains("commentary")
486 && !r.message.contains("effective date")
487 && !r.message.contains("-> suggest")
488 }),
489 "optional gaps must not be recommended: {recs:?}"
490 );
491 let help = recs
492 .iter()
493 .find(|r| r.message.contains("no `-> help`") && r.message.contains("x"))
494 .expect("missing help");
495 assert!(
496 help.message.contains("Consider adding a message"),
497 "got: {}",
498 help.message
499 );
500 assert_eq!(help.spec, "pricing");
501 assert_eq!(help.effective_from, None);
502 }
503
504 #[test]
505 fn clean_spec_has_no_recommendations() {
506 let engine = load(
507 r#"spec pricing 2026-01-01
508"""
509Bulk pricing.
510"""
511
512data qty: number
513 -> minimum 0
514 -> maximum 1000000
515 -> help "Order quantity."
516
517rule total: qty
518"#,
519 );
520 assert!(engine.quality().is_empty(), "got: {:?}", engine.quality());
521 }
522
523 #[test]
524 fn deprecated_standalone_with_emits_quality_recommendation() {
525 let engine = load(
526 r#"spec inner
527data x: number
528
529spec outer
530uses i: inner
531with i.x: 42
532rule r: i.x
533"#,
534 );
535 let recs = engine.quality();
536 let hit = recs
537 .iter()
538 .find(|r| r.message.contains("deprecated") && r.spec == "outer")
539 .expect("deprecated standalone with must produce quality recommendation");
540 assert!(hit.message.contains("-> with"), "got: {}", hit.message);
541 assert_eq!(hit.source_location.span.line, 6);
542 }
543
544 #[test]
545 fn block_uses_binding_has_no_deprecated_recommendation() {
546 let engine = load(
547 r#"spec inner
548data x: number
549
550spec outer
551uses i: inner
552 -> with x: 42
553rule r: i.x
554"#,
555 );
556 assert!(
557 engine
558 .quality()
559 .iter()
560 .all(|r| !r.message.contains("deprecated")),
561 "block syntax must not flag deprecated: {:?}",
562 engine.quality()
563 );
564 }
565
566 #[test]
567 fn number_without_bounds() {
568 let engine = load(
569 r#"spec pricing 2026-01-01
570"""
571x
572"""
573
574data qty: number
575 -> help "Order quantity."
576rule total: qty
577"#,
578 );
579 let hit = engine
580 .quality()
581 .into_iter()
582 .find(|r| {
583 r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("qty")
584 })
585 .expect("missing bounds");
586 assert!(
587 hit.message.contains("Consider adding bounds"),
588 "got: {}",
589 hit.message
590 );
591 }
592
593 #[test]
594 fn number_with_only_minimum_still_flagged() {
595 let engine = load(
596 r#"spec pricing 2026-01-01
597"""
598x
599"""
600
601data qty: number
602 -> minimum 0
603 -> help "Order quantity."
604rule total: qty
605"#,
606 );
607 assert!(
608 engine.quality().iter().any(|r| {
609 r.message.contains("no `-> maximum`")
610 && !r.message.contains("no `-> minimum` or")
611 && r.message.contains("qty")
612 }),
613 "only minimum must flag missing maximum: {:?}",
614 engine.quality()
615 );
616 }
617
618 #[test]
619 fn number_with_only_maximum_still_flagged() {
620 let engine = load(
621 r#"spec pricing 2026-01-01
622"""
623x
624"""
625
626data qty: number
627 -> maximum 100
628 -> help "Order quantity."
629rule total: qty
630"#,
631 );
632 assert!(
633 engine.quality().iter().any(|r| {
634 r.message.contains("no `-> minimum`")
635 && !r.message.contains("or `-> maximum`")
636 && r.message.contains("qty")
637 }),
638 "only maximum must flag missing minimum: {:?}",
639 engine.quality()
640 );
641 }
642
643 #[test]
644 fn number_with_min_and_max_clean() {
645 let engine = load(
646 r#"spec pricing 2026-01-01
647"""
648x
649"""
650
651data qty: number
652 -> minimum 0
653 -> maximum 100
654 -> help "Order quantity."
655rule total: qty
656"#,
657 );
658 assert!(
659 !engine
660 .quality()
661 .iter()
662 .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
663 "min+max must not flag bounds: {:?}",
664 engine.quality()
665 );
666 }
667
668 #[test]
669 fn measure_and_ratio_without_bounds() {
670 let engine = load(
671 r#"spec pricing 2026-01-01
672"""
673x
674"""
675
676data price: measure
677 -> unit eur: 1
678 -> help "Unit price."
679data discount: ratio
680 -> help "Discount rate."
681rule total: price
682rule rate: discount
683"#,
684 );
685 let recs = engine.quality();
686 assert!(
687 recs.iter().any(|r| {
688 r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("price")
689 }),
690 "measure must flag: {recs:?}"
691 );
692 assert!(
693 recs.iter().any(|r| {
694 r.message.contains("no `-> minimum` or `-> maximum`")
695 && r.message.contains("discount")
696 }),
697 "ratio must flag: {recs:?}"
698 );
699 }
700
701 #[test]
702 fn text_not_flagged_for_bounds() {
703 let engine = load(
704 r#"spec pricing 2026-01-01
705"""
706x
707"""
708
709data status: text
710 -> help "Status."
711rule ok: status is "active"
712"#,
713 );
714 assert!(
715 !engine
716 .quality()
717 .iter()
718 .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
719 "text must not get bounds rec: {:?}",
720 engine.quality()
721 );
722 }
723
724 #[test]
725 fn data_missing_help() {
726 let engine = load(
727 r#"spec pricing 2026-01-01
728"""
729x
730"""
731
732data qty: number
733rule total: qty
734"#,
735 );
736 let hit = engine
737 .quality()
738 .into_iter()
739 .find(|r| r.message.contains("no `-> help`") && r.message.contains("qty"))
740 .expect("missing help");
741 assert!(
742 hit.message.contains("Consider adding a message"),
743 "got: {}",
744 hit.message
745 );
746 assert_eq!(hit.spec, "pricing");
747 assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
748 assert!(
749 hit.to_string().starts_with(
750 "In spec 'pricing' (effective from 2026-01-01): `qty` has no `-> help`"
751 ),
752 "Display must include effective_from, got: {}",
753 hit
754 );
755 assert!(
756 hit.to_string().contains(" at test.lemma:"),
757 "Display must append source location, got: {}",
758 hit
759 );
760 }
761
762 #[test]
763 fn text_without_options() {
764 let engine = load(
765 r#"spec pricing 2026-01-01
766"""
767x
768"""
769
770data status: text
771 -> help "Status."
772rule ok: status is "active"
773"#,
774 );
775 let hit = engine
776 .quality()
777 .into_iter()
778 .find(|r| r.message.contains("accepts any text") && r.message.contains("status"))
779 .expect("text without options");
780 assert!(
781 hit.message.contains("offer choices"),
782 "got: {}",
783 hit.message
784 );
785 assert_eq!(hit.spec, "pricing");
786 assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
787 }
788
789 #[test]
790 fn veto_as_rejection_cascade() {
791 let engine = load(
792 r#"spec eligibility 2026-01-01
793"""
794Age gate.
795"""
796
797data age: number
798 -> help "Customer age."
799
800rule is_eligible: true
801 unless age < 18 then veto "Must be 18+"
802"#,
803 );
804 let hit = engine
805 .quality()
806 .into_iter()
807 .find(|r| {
808 r.message.contains("treats a yes/no outcome as veto")
809 && r.message.contains("is_eligible")
810 })
811 .expect("veto cascade");
812 assert_eq!(hit.spec, "eligibility");
813 assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
814 }
815
816 #[test]
817 fn boolean_denial_is_not_cascade() {
818 let engine = load(
819 r#"spec eligibility 2026-01-01
820"""
821Age gate.
822"""
823
824data age: number
825 -> help "Customer age."
826
827rule is_eligible: true
828 unless age < 18 then false
829"#,
830 );
831 assert!(!engine
832 .quality()
833 .iter()
834 .any(|r| r.message.contains("treats a yes/no outcome as veto")));
835 }
836
837 #[test]
838 fn stdlib_dependency_not_reported() {
839 let engine = load(
840 r#"spec ship 2026-01-01
841"""
842Weight check.
843"""
844
845uses lemma units
846
847data package_weight: units.mass
848 -> help "Package weight."
849
850rule heavy: package_weight > 20 kilogram
851"#,
852 );
853 let recs = engine.quality();
854 assert!(
855 recs.iter().all(|r| r.spec != "units"),
856 "stdlib units must not appear: {recs:?}"
857 );
858 }
859
860 #[test]
861 fn temporal_slices_distinct_by_effective_from() {
862 let engine = load(
863 r#"spec pricing 1933-01-01
864"""
865Old.
866"""
867
868data qty: number
869rule total: qty
870
871spec pricing 2026-01-01
872"""
873New.
874"""
875
876data qty: number
877rule total: qty
878"#,
879 );
880 let helps: Vec<_> = engine
881 .quality()
882 .into_iter()
883 .filter(|r| r.message.contains("no `-> help`"))
884 .collect();
885 assert_eq!(helps.len(), 2, "got: {helps:?}");
886 assert!(
887 helps.iter().any(|r| {
888 r.spec == "pricing" && effective_display(r).as_deref() == Some("1933-01-01")
889 }),
890 "missing 1933 slice: {helps:?}"
891 );
892 assert!(
893 helps.iter().any(|r| {
894 r.spec == "pricing" && effective_display(r).as_deref() == Some("2026-01-01")
895 }),
896 "missing 2026 slice: {helps:?}"
897 );
898 assert!(
899 helps
900 .iter()
901 .all(|r| !r.message.contains("1933") && !r.message.contains("2026")),
902 "message must not carry temporal identity: {helps:?}"
903 );
904 let displays: Vec<String> = helps.iter().map(|r| r.to_string()).collect();
905 assert!(
906 displays
907 .iter()
908 .any(|s| s.contains("In spec 'pricing' (effective from 1933-01-01)")),
909 "Display missing 1933: {displays:?}"
910 );
911 assert!(
912 displays
913 .iter()
914 .any(|s| s.contains("In spec 'pricing' (effective from 2026-01-01)")),
915 "Display missing 2026: {displays:?}"
916 );
917 }
918
919 #[test]
920 fn recommendation_wire_json_uses_source_not_source_location() {
921 let engine = load(
922 r#"spec pricing 2026-01-01
923"""
924x
925"""
926
927data qty: number
928rule total: qty
929"#,
930 );
931 let hit = engine
932 .quality()
933 .into_iter()
934 .find(|r| r.message.contains("no `-> help`"))
935 .expect("missing help");
936 let json = serde_json::to_value(&hit).expect("serialize");
937 assert!(json.get("source").is_some(), "wire must use source: {json}");
938 assert!(
939 json.get("source_location").is_none(),
940 "wire must not expose source_location: {json}"
941 );
942 assert_eq!(json["source"]["attribute"], serde_json::json!("test.lemma"));
943 let round: Recommendation = serde_json::from_value(json).expect("deserialize");
944 assert_eq!(round.spec, "pricing");
945 assert!(round.message.contains("no `-> help`"));
946 }
947
948 fn and_chain_spec(rule_body: &str) -> String {
949 format!(
950 r#"spec gate 2026-01-01
951"""
952Gate.
953"""
954
955data ready: boolean
956 -> help "Ready?"
957data eligible: boolean
958 -> help "Eligible?"
959
960rule pass: no
961 {rule_body}
962"#
963 )
964 }
965
966 fn ambiguous_and_hits(engine: &Engine) -> Vec<Recommendation> {
967 engine
968 .quality()
969 .into_iter()
970 .filter(|r| r.message.contains("Boolean `and` mixes a bare name"))
971 .collect()
972 }
973
974 #[test]
975 fn flags_not_ref_and_bare() {
976 let engine = load(&and_chain_spec("unless not ready and eligible then yes"));
977 let hits = ambiguous_and_hits(&engine);
978 assert_eq!(hits.len(), 1, "got: {hits:?}");
979 assert_eq!(hits[0].spec, "gate");
980 assert!(
981 hits[0].message.contains("Prefer parallel probes"),
982 "got: {}",
983 hits[0].message
984 );
985 }
986
987 #[test]
988 fn flags_bare_and_is_false() {
989 let engine = load(&and_chain_spec(
990 "unless ready and eligible is false then yes",
991 ));
992 let hits = ambiguous_and_hits(&engine);
993 assert_eq!(hits.len(), 1, "got: {hits:?}");
994 assert_eq!(hits[0].spec, "gate");
995 }
996
997 #[test]
998 fn clean_not_and_is_true() {
999 let engine = load(&and_chain_spec(
1000 "unless not ready and eligible is true then yes",
1001 ));
1002 assert!(
1003 ambiguous_and_hits(&engine).is_empty(),
1004 "got: {:?}",
1005 ambiguous_and_hits(&engine)
1006 );
1007 }
1008
1009 #[test]
1010 fn clean_is_false_and_is_true() {
1011 let engine = load(&and_chain_spec(
1012 "unless ready is false and eligible is true then yes",
1013 ));
1014 assert!(
1015 ambiguous_and_hits(&engine).is_empty(),
1016 "got: {:?}",
1017 ambiguous_and_hits(&engine)
1018 );
1019 }
1020
1021 #[test]
1022 fn clean_unary_not() {
1023 let engine = load(&and_chain_spec("unless not ready then yes"));
1024 assert!(
1025 ambiguous_and_hits(&engine).is_empty(),
1026 "got: {:?}",
1027 ambiguous_and_hits(&engine)
1028 );
1029 }
1030
1031 #[test]
1032 fn clean_bare_and_bare() {
1033 let engine = load(&and_chain_spec("unless ready and eligible then yes"));
1034 assert!(
1035 ambiguous_and_hits(&engine).is_empty(),
1036 "got: {:?}",
1037 ambiguous_and_hits(&engine)
1038 );
1039 }
1040
1041 fn redundant_unless_hits(engine: &Engine) -> Vec<Recommendation> {
1042 engine
1043 .quality()
1044 .into_iter()
1045 .filter(|r| r.message.contains("single `unless … then yes`"))
1046 .collect()
1047 }
1048
1049 #[test]
1050 fn flags_redundant_no_unless_yes() {
1051 let engine = load(&and_chain_spec("unless ready and eligible then yes"));
1052 let hits = redundant_unless_hits(&engine);
1053 assert_eq!(hits.len(), 1, "got: {hits:?}");
1054 assert!(
1055 hits[0].message.contains("ready and eligible"),
1056 "{}",
1057 hits[0].message
1058 );
1059 }
1060
1061 #[test]
1062 fn flags_redundant_false_unless_true() {
1063 let engine = load(
1064 r#"spec gate
1065data ready: boolean
1066
1067rule pass: false
1068 unless ready then true
1069"#,
1070 );
1071 let hits = redundant_unless_hits(&engine);
1072 assert_eq!(hits.len(), 1, "got: {hits:?}");
1073 }
1074
1075 #[test]
1076 fn flags_redundant_no_unless_not_ready_then_yes() {
1077 let engine = load(&and_chain_spec("unless not ready then yes"));
1078 let hits = redundant_unless_hits(&engine);
1079 assert_eq!(hits.len(), 1, "got: {hits:?}");
1080 assert!(hits[0].message.contains("not ready"), "{}", hits[0].message);
1081 }
1082
1083 #[test]
1084 fn clean_symmetric_yes_unless_no() {
1085 let engine = load(
1086 r#"spec gate
1087data ready: boolean
1088data eligible: boolean
1089
1090rule pass: yes
1091 unless ready and eligible then no
1092"#,
1093 );
1094 assert!(
1095 redundant_unless_hits(&engine).is_empty(),
1096 "got: {:?}",
1097 redundant_unless_hits(&engine)
1098 );
1099 }
1100
1101 #[test]
1102 fn clean_multi_unless_discount() {
1103 let engine = load(
1104 r#"spec vip_discount
1105data qty: number
1106 -> minimum 0
1107data is_vip: boolean
1108
1109rule discount: 0%
1110 unless qty >= 10 then 10%
1111 unless qty >= 50 then 20%
1112 unless is_vip then 25%
1113"#,
1114 );
1115 assert!(
1116 redundant_unless_hits(&engine).is_empty(),
1117 "got: {:?}",
1118 redundant_unless_hits(&engine)
1119 );
1120 }
1121
1122 #[test]
1123 fn clean_multi_unless_needs_jacket() {
1124 let engine = load(
1125 r#"spec weather
1126uses lemma units
1127
1128data temperature: measure
1129 -> unit celsius: 1.0
1130data is_raining: boolean
1131data wind_speed: number
1132
1133rule needs_jacket: no
1134 unless temperature < 15 celsius then yes
1135 unless is_raining then yes
1136 unless wind_speed > 20 then yes
1137"#,
1138 );
1139 assert!(
1140 redundant_unless_hits(&engine).is_empty(),
1141 "got: {:?}",
1142 redundant_unless_hits(&engine)
1143 );
1144 }
1145
1146 #[test]
1147 fn clean_direct_boolean_rule() {
1148 let engine = load(
1149 r#"spec gate
1150data ready: boolean
1151data eligible: boolean
1152
1153rule pass: ready and eligible
1154"#,
1155 );
1156 assert!(
1157 redundant_unless_hits(&engine).is_empty(),
1158 "got: {:?}",
1159 redundant_unless_hits(&engine)
1160 );
1161 }
1162
1163 #[test]
1164 fn clean_non_boolean_unless() {
1165 let engine = load(
1166 r#"spec pricing
1167data qty: number
1168
1169rule discount: 0%
1170 unless qty >= 10 then 10%
1171"#,
1172 );
1173 assert!(
1174 redundant_unless_hits(&engine).is_empty(),
1175 "got: {:?}",
1176 redundant_unless_hits(&engine)
1177 );
1178 }
1179
1180 #[test]
1181 fn deprecated_unit_without_colon_emits_quality_recommendation() {
1182 let engine = load(
1183 r#"spec money_spec
1184data money: measure
1185 -> unit eur 1.00
1186 -> help "Money amount."
1187"#,
1188 );
1189 let hit = engine
1190 .quality()
1191 .into_iter()
1192 .find(|r| r.message.contains("deprecated") && r.spec == "money_spec")
1193 .expect("deprecated unit without colon must produce quality recommendation");
1194 assert!(
1195 hit.message.contains("unit") && hit.message.contains(":"),
1196 "got: {}",
1197 hit.message
1198 );
1199 }
1200
1201 #[test]
1202 fn unit_with_colon_has_no_deprecated_unit_recommendation() {
1203 let engine = load(
1204 r#"spec money_spec
1205data money: measure
1206 -> unit eur: 1.00
1207 -> help "Money amount."
1208"#,
1209 );
1210 assert!(
1211 engine
1212 .quality()
1213 .iter()
1214 .all(|r| !r.message.contains("deprecated")),
1215 "colon unit syntax must not flag deprecated: {:?}",
1216 engine.quality()
1217 );
1218 }
1219}