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