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