Skip to main content

lemma/api/
show.rs

1//! Show document (a JSON Schema for evaluation-data inputs) and the ShowRule / ShowExpression
2//! shapes under its `x-lemma-rules`.
3
4use crate::api::types::LemmaType;
5use crate::api::value::RuleResultValue;
6use crate::parsing::ast::{
7    ArithmeticComputation, CalendarPeriodUnit, ComparisonComputation, ConversionTypeKind,
8    DateCalendarKind, DateRelativeKind, MathematicalComputation, RangeBound, RoundingMode,
9};
10use crate::planning::execution_plan::{
11    ExecutionPlan, Show as DomainShow, ShowBranch as DomainShowBranch,
12    ShowConversionTarget as DomainConversionTarget, ShowData as DomainShowData,
13    ShowExpression as DomainShowExpression, ShowRule as DomainShowRule,
14};
15pub use crate::planning::execution_plan::{ShowListCommand, SortDirection};
16use crate::planning::semantics::{
17    LemmaType as DomainLemmaType, PathSegment as DomainPathSegment, SpecIdentity,
18    TypeSpecification as DomainTypeSpecification,
19};
20use crate::planning::show_expression::take_operand;
21use crate::result_value::{
22    type_scoped_result_value_from_literal, RuleResultValue as DomainRuleResultValue,
23};
24use crate::Engine;
25use indexmap::IndexMap;
26use serde::{Deserialize, Serialize};
27use serde_json::{json, Map, Value as JsonValue};
28use std::collections::{BTreeMap, BTreeSet};
29use std::sync::Arc;
30
31/// One hop on a Show data or rule path: a `uses` alias, or (with `row`) a row written
32/// in the data list named `uses`.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct PathSegment {
35    pub uses: String,
36    /// Row number (1-based) when this hop is a spec-written row of list `uses`.
37    #[serde(skip_serializing_if = "Option::is_none", default)]
38    pub row: Option<usize>,
39    #[serde(skip_serializing_if = "Option::is_none", default)]
40    pub repository: Option<String>,
41    pub spec: String,
42}
43
44impl From<&DomainPathSegment> for PathSegment {
45    fn from(segment: &DomainPathSegment) -> Self {
46        Self {
47            uses: segment.uses.to_string(),
48            row: segment.row,
49            repository: segment.repository.as_ref().map(|arc| arc.to_string()),
50            spec: segment.spec.to_string(),
51        }
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ShowConversionTarget {
58    Type(ConversionTypeKind),
59    Unit { unit_name: String },
60}
61
62impl From<&DomainConversionTarget> for ShowConversionTarget {
63    fn from(target: &DomainConversionTarget) -> Self {
64        match target {
65            DomainConversionTarget::Type(kind) => Self::Type(*kind),
66            DomainConversionTarget::Unit { unit_name } => Self::Unit {
67                unit_name: unit_name.clone(),
68            },
69        }
70    }
71}
72
73/// Resolved expression on a Show rule branch.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(tag = "type", rename_all = "snake_case")]
76pub enum ShowExpression {
77    Literal {
78        #[serde(flatten)]
79        value: Box<RuleResultValue>,
80    },
81    Data {
82        name: String,
83    },
84    Rule {
85        name: String,
86    },
87    And {
88        operands: Vec<ShowExpression>,
89    },
90    Or {
91        operands: Vec<ShowExpression>,
92    },
93    Not {
94        operand: Box<ShowExpression>,
95    },
96    Arithmetic {
97        first: Box<ShowExpression>,
98        steps: Vec<ShowArithmeticStep>,
99    },
100    Comparison {
101        op: ComparisonComputation,
102        left: Box<ShowExpression>,
103        right: Box<ShowExpression>,
104    },
105    UnitConversion {
106        operand: Box<ShowExpression>,
107        targets: Vec<ShowConversionTarget>,
108    },
109    Math {
110        op: MathematicalComputation,
111        operand: Box<ShowExpression>,
112    },
113    StepRounding {
114        mode: RoundingMode,
115        operand: Box<ShowExpression>,
116        #[serde(skip_serializing_if = "Option::is_none", default)]
117        step: Option<Box<ShowExpression>>,
118    },
119    Veto {
120        #[serde(skip_serializing_if = "Option::is_none", default)]
121        message: Option<String>,
122    },
123    Now,
124    DateRelative {
125        kind: DateRelativeKind,
126        operand: Box<ShowExpression>,
127    },
128    DateCalendar {
129        kind: DateCalendarKind,
130        unit: CalendarPeriodUnit,
131        operand: Box<ShowExpression>,
132    },
133    RangeLiteral {
134        from: Box<ShowExpression>,
135        to: Box<ShowExpression>,
136    },
137    PastFutureRange {
138        kind: DateRelativeKind,
139        operand: Box<ShowExpression>,
140    },
141    RangeContainment {
142        value: Box<ShowExpression>,
143        range: Box<ShowExpression>,
144    },
145    RangeBound {
146        bound: RangeBound,
147        operand: Box<ShowExpression>,
148    },
149    IsVeto {
150        operand: Box<ShowExpression>,
151    },
152    SpecMember {
153        base: String,
154        member: String,
155    },
156    Member {
157        base: Box<ShowExpression>,
158        member: String,
159    },
160    RowField {
161        field: String,
162        #[serde(default, skip_serializing_if = "Option::is_none")]
163        qualifier: Option<Vec<String>>,
164    },
165    RowValue,
166    RowFieldValue {
167        field: String,
168        value: Box<ShowExpression>,
169    },
170    ListCommand {
171        list: Box<ShowExpression>,
172        command: ShowListCommand,
173        #[serde(skip_serializing_if = "Option::is_none", default)]
174        direction: Option<SortDirection>,
175        args: Vec<ShowExpression>,
176    },
177}
178
179/// One operator step of an `arithmetic` node: `op operand`.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct ShowArithmeticStep {
182    pub op: ArithmeticComputation,
183    pub operand: ShowExpression,
184}
185
186impl From<&DomainShowExpression> for ShowExpression {
187    fn from(expression: &DomainShowExpression) -> Self {
188        expression.fold(convert_show_node)
189    }
190}
191
192/// One API node from its domain node and the API operands already converted, in
193/// [`DomainShowExpression::operands`] order.
194fn convert_show_node(node: &DomainShowExpression, operands: Vec<ShowExpression>) -> ShowExpression {
195    let mut operands = operands.into_iter();
196    match node {
197        DomainShowExpression::Literal(typed) => {
198            let domain_value = type_scoped_result_value_from_literal(
199                &typed.to_literal(),
200                typed.lemma_type.as_ref(),
201            )
202            .unwrap_or_else(|failure| {
203                panic!(
204                    "BUG: show branch literal failed type_scoped_result_value_from_literal: {}",
205                    crate::result_value::rule_result_value_failure_message(failure)
206                )
207            });
208            ShowExpression::Literal {
209                value: Box::new(RuleResultValue::from(&domain_value)),
210            }
211        }
212        DomainShowExpression::Data { name } => ShowExpression::Data { name: name.clone() },
213        DomainShowExpression::Rule { name } => ShowExpression::Rule { name: name.clone() },
214        DomainShowExpression::And { .. } => ShowExpression::And {
215            operands: operands.collect(),
216        },
217        DomainShowExpression::Or { .. } => ShowExpression::Or {
218            operands: operands.collect(),
219        },
220        DomainShowExpression::Not { .. } => ShowExpression::Not {
221            operand: Box::new(take_operand(&mut operands)),
222        },
223        DomainShowExpression::Arithmetic { steps, .. } => {
224            let first = Box::new(take_operand(&mut operands));
225            let steps = steps
226                .iter()
227                .map(|step| ShowArithmeticStep {
228                    op: step.op.clone(),
229                    operand: take_operand(&mut operands),
230                })
231                .collect();
232            ShowExpression::Arithmetic { first, steps }
233        }
234        DomainShowExpression::Comparison { op, .. } => {
235            let left = Box::new(take_operand(&mut operands));
236            let right = Box::new(take_operand(&mut operands));
237            ShowExpression::Comparison {
238                op: op.clone(),
239                left,
240                right,
241            }
242        }
243        DomainShowExpression::UnitConversion { targets, .. } => ShowExpression::UnitConversion {
244            operand: Box::new(take_operand(&mut operands)),
245            targets: targets.iter().map(ShowConversionTarget::from).collect(),
246        },
247        DomainShowExpression::Math { op, .. } => ShowExpression::Math {
248            op: op.clone(),
249            operand: Box::new(take_operand(&mut operands)),
250        },
251        DomainShowExpression::StepRounding { mode, step, .. } => {
252            let operand = Box::new(take_operand(&mut operands));
253            ShowExpression::StepRounding {
254                mode: *mode,
255                operand,
256                step: step.as_ref().map(|_| Box::new(take_operand(&mut operands))),
257            }
258        }
259        DomainShowExpression::Veto { message } => ShowExpression::Veto {
260            message: message.clone(),
261        },
262        DomainShowExpression::Now => ShowExpression::Now,
263        DomainShowExpression::DateRelative { kind, .. } => ShowExpression::DateRelative {
264            kind: *kind,
265            operand: Box::new(take_operand(&mut operands)),
266        },
267        DomainShowExpression::DateCalendar { kind, unit, .. } => ShowExpression::DateCalendar {
268            kind: *kind,
269            unit: *unit,
270            operand: Box::new(take_operand(&mut operands)),
271        },
272        DomainShowExpression::RangeLiteral { .. } => {
273            let from = Box::new(take_operand(&mut operands));
274            let to = Box::new(take_operand(&mut operands));
275            ShowExpression::RangeLiteral { from, to }
276        }
277        DomainShowExpression::PastFutureRange { kind, .. } => ShowExpression::PastFutureRange {
278            kind: *kind,
279            operand: Box::new(take_operand(&mut operands)),
280        },
281        DomainShowExpression::RangeContainment { .. } => {
282            let value = Box::new(take_operand(&mut operands));
283            let range = Box::new(take_operand(&mut operands));
284            ShowExpression::RangeContainment { value, range }
285        }
286        DomainShowExpression::RangeBound { bound, .. } => ShowExpression::RangeBound {
287            bound: *bound,
288            operand: Box::new(take_operand(&mut operands)),
289        },
290        DomainShowExpression::IsVeto { .. } => ShowExpression::IsVeto {
291            operand: Box::new(take_operand(&mut operands)),
292        },
293        DomainShowExpression::SpecMember { base, member } => ShowExpression::SpecMember {
294            base: base.clone(),
295            member: member.clone(),
296        },
297        DomainShowExpression::Member { member, .. } => ShowExpression::Member {
298            base: Box::new(take_operand(&mut operands)),
299            member: member.clone(),
300        },
301        DomainShowExpression::RowField { field, qualifier } => ShowExpression::RowField {
302            field: field.clone(),
303            qualifier: qualifier.clone(),
304        },
305        DomainShowExpression::RowValue => ShowExpression::RowValue,
306        DomainShowExpression::RowFieldValue { field, .. } => ShowExpression::RowFieldValue {
307            field: field.clone(),
308            value: Box::new(take_operand(&mut operands)),
309        },
310        DomainShowExpression::ListCommand {
311            command, direction, ..
312        } => {
313            let list = Box::new(take_operand(&mut operands));
314            ShowExpression::ListCommand {
315                list,
316                command: *command,
317                direction: *direction,
318                args: operands.collect(),
319            }
320        }
321    }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325pub struct ShowBranch {
326    #[serde(skip_serializing_if = "Option::is_none", default)]
327    pub condition: Option<ShowExpression>,
328    pub result: ShowExpression,
329}
330
331impl From<&DomainShowBranch> for ShowBranch {
332    fn from(branch: &DomainShowBranch) -> Self {
333        Self {
334            condition: branch.condition.as_ref().map(ShowExpression::from),
335            result: ShowExpression::from(&branch.result),
336        }
337    }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct ShowRule {
342    #[serde(rename = "type")]
343    pub lemma_type: LemmaType,
344    pub path: Vec<PathSegment>,
345    pub branches: Vec<ShowBranch>,
346    pub depends_on_rules: Vec<String>,
347}
348
349impl From<&DomainShowRule> for ShowRule {
350    fn from(rule: &DomainShowRule) -> Self {
351        Self {
352            lemma_type: LemmaType::from(&rule.lemma_type),
353            path: rule.path.iter().map(PathSegment::from).collect(),
354            branches: rule.branches.iter().map(ShowBranch::from).collect(),
355            depends_on_rules: rule
356                .depends_on_rules
357                .iter()
358                .map(|path| path.input_key())
359                .collect(),
360        }
361    }
362}
363
364const JSON_SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
365
366/// Run-data boolean text (`BooleanValue::from_str`): case-insensitive, surrounding whitespace.
367const BOOLEAN_TEXT_PATTERN: &str =
368    r"^\s*(?:[Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|[Yy][Ee][Ss]|[Nn][Oo])\s*$";
369
370/// The Show document: a Draft 2020-12 JSON Schema for the spec's evaluation-data inputs.
371///
372/// Standard keywords carry what JSON Schema expresses (`type`, `enum`, `minLength` /
373/// `maxLength`, `format`, `default`, `examples`, `items`, ...; `pattern` only on the
374/// boolean string alternative). Date inputs are `oneOf` `date` | `date-time`; time
375/// uses `format: time`. Number, measure, ratio and ranges are plain strings; the
376/// engine validates literals on evaluate. `x-lemma-*` keywords carry the rest,
377/// including this spec's own rules under `x-lemma-rules`. `$defs` holds one row object
378/// schema per instance-list row spec.
379pub fn show_document(engine: &Engine, show: &DomainShow) -> JsonValue {
380    let consumer = match engine.plan_matching_show(show) {
381        Some(plan) => Some(plan),
382        None if !show_has_instance_list(show) => None,
383        None => panic!(
384            "BUG: Show '{}' has instance-list inputs but no matching execution plan",
385            show.spec
386        ),
387    };
388    let mut rows = RowSchemas::new(engine, consumer);
389    let mut document = Map::new();
390    document.insert("$schema".to_string(), json!(JSON_SCHEMA_DIALECT));
391    document.insert("title".to_string(), json!(show.spec));
392    if let Some(commentary) = &show.commentary {
393        document.insert("description".to_string(), json!(commentary));
394    }
395    document.extend(input_object_schema(show, &mut rows));
396    let (defs, row_field_needed_by_rules) = rows.into_defs();
397    if !defs.is_empty() {
398        document.insert("$defs".to_string(), JsonValue::Object(defs));
399    }
400    if !row_field_needed_by_rules.is_empty() {
401        // Parent-specific: which of this Show's rules need each `$defs` row field.
402        // Kept off `$defs` so `LemmaRow_*` schemas are identical across Show documents
403        // (OpenAPI shares them by row identity).
404        document.insert(
405            "x-lemma-row-field-needed-by-rules".to_string(),
406            JsonValue::Object(row_field_needed_by_rules),
407        );
408    }
409    if let Some(repository) = &show.repository {
410        document.insert("x-lemma-repository".to_string(), json!(repository));
411    }
412    if let Some(from) = &show.effective_from {
413        document.insert("x-lemma-effective-from".to_string(), to_json(from));
414    }
415    if let Some(to) = &show.effective_to {
416        document.insert("x-lemma-effective-to".to_string(), to_json(to));
417    }
418    document.insert("x-lemma-start-line".to_string(), json!(show.start_line));
419    if let Some(source_type) = &show.source_type {
420        document.insert("x-lemma-source-type".to_string(), to_json(source_type));
421    }
422    document.insert("x-lemma-meta".to_string(), to_json(&show.meta));
423    let types: IndexMap<&String, LemmaType> = show
424        .types
425        .iter()
426        .map(|(name, lemma_type)| (name, LemmaType::from(lemma_type)))
427        .collect();
428    document.insert("x-lemma-types".to_string(), to_json(&types));
429    let rules: IndexMap<&String, ShowRule> = show
430        .rules
431        .iter()
432        .map(|(name, rule)| (name, ShowRule::from(rule)))
433        .collect();
434    document.insert("x-lemma-rules".to_string(), to_json(&rules));
435    JsonValue::Object(document)
436}
437
438fn to_json<T: Serialize>(value: &T) -> JsonValue {
439    serde_json::to_value(value).expect("BUG: Show document parts serialize to JSON")
440}
441
442fn show_has_instance_list(show: &DomainShow) -> bool {
443    show.data.values().any(|data| {
444        matches!(
445            &data.lemma_type.specifications,
446            DomainTypeSpecification::List { element, .. }
447                if matches!(&element.specifications, DomainTypeSpecification::Spec { .. })
448        )
449    })
450}
451
452/// `type`, `additionalProperties`, `properties` and `required` for the data of `show`.
453/// `required`: data a rule needs that has no committed value. Lists are never required:
454/// an absent list is empty.
455fn input_object_schema(show: &DomainShow, rows: &mut RowSchemas) -> Map<String, JsonValue> {
456    let mut properties = Map::new();
457    let mut required = Vec::new();
458    for (name, data) in &show.data {
459        properties.insert(
460            name.clone(),
461            property_schema(data, rows, &data.needed_by_rules),
462        );
463        let is_list = matches!(
464            data.lemma_type.specifications,
465            DomainTypeSpecification::List { .. }
466        );
467        if data.value.is_none() && !data.needed_by_rules.is_empty() && !is_list {
468            required.push(name.clone());
469        }
470    }
471    required.sort();
472    let mut schema = Map::new();
473    schema.insert("type".to_string(), json!("object"));
474    schema.insert("additionalProperties".to_string(), json!(true));
475    schema.insert("properties".to_string(), JsonValue::Object(properties));
476    if !required.is_empty() {
477        schema.insert("required".to_string(), json!(required));
478    }
479    schema
480}
481
482/// Row `$defs` object: local types, plus any other `show.data` slots on the row
483/// (imported fields, local instance lists).
484///
485/// `x-lemma-needed-by-rules` on these properties is always empty: parent consumers
486/// differ per Show document, and `$defs` are keyed by row identity so every Show
487/// must render the same `LemmaRow_*` schema. Parent field consumers live on the
488/// document as `x-lemma-row-field-needed-by-rules`.
489fn row_object_schema(show: &DomainShow, rows: &mut RowSchemas) -> Map<String, JsonValue> {
490    let mut properties = Map::new();
491    let mut required = Vec::new();
492    for (name, lemma_type) in &show.types {
493        let stub;
494        let entry = match show.data.get(name) {
495            Some(data) => data,
496            None => {
497                stub = DomainShowData {
498                    lemma_type: lemma_type.clone(),
499                    path: Vec::new(),
500                    value: None,
501                    suggestion: None,
502                    needed_by_rules: Vec::new(),
503                };
504                &stub
505            }
506        };
507        let is_list = matches!(
508            entry.lemma_type.specifications,
509            DomainTypeSpecification::List { .. }
510        );
511        if entry.value.is_none() && !is_list {
512            required.push(name.clone());
513        }
514        properties.insert(name.clone(), property_schema(entry, rows, &[]));
515    }
516    for (name, data) in &show.data {
517        if properties.contains_key(name) {
518            continue;
519        }
520        let is_list = matches!(
521            data.lemma_type.specifications,
522            DomainTypeSpecification::List { .. }
523        );
524        if data.value.is_none() && !is_list {
525            required.push(name.clone());
526        }
527        properties.insert(name.clone(), property_schema(data, rows, &[]));
528    }
529    required.sort();
530    let mut schema = Map::new();
531    schema.insert("type".to_string(), json!("object"));
532    schema.insert("additionalProperties".to_string(), json!(true));
533    schema.insert("properties".to_string(), JsonValue::Object(properties));
534    if !required.is_empty() {
535        schema.insert("required".to_string(), json!(required));
536    }
537    schema
538}
539
540/// Property names of a row `$defs` schema: local types, then any other `show.data` keys.
541fn row_property_names(show: &DomainShow) -> BTreeSet<String> {
542    let mut names: BTreeSet<String> = show.types.keys().cloned().collect();
543    names.extend(show.data.keys().cloned());
544    names
545}
546
547fn property_schema(
548    data: &DomainShowData,
549    rows: &mut RowSchemas,
550    needed_by_rules: &[String],
551) -> JsonValue {
552    let mut schema = type_schema(&data.lemma_type, rows);
553    let help = specification_help(&data.lemma_type.specifications);
554    if !help.is_empty() {
555        schema.insert("description".to_string(), json!(help));
556    }
557    if let Some(value) = &data.value {
558        // A reference field is wiring, not a run-data literal. Emitting a partial
559        // default would replace source rows and drop those bindings.
560        if !rule_result_value_has_reference(value) {
561            schema.insert(
562                "default".to_string(),
563                input_literal(value, &data.lemma_type, rows),
564            );
565        }
566    }
567    if let Some(suggestion) = &data.suggestion {
568        schema.insert(
569            "examples".to_string(),
570            json!([input_literal(suggestion, &data.lemma_type, rows)]),
571        );
572    }
573    schema.insert(
574        "x-lemma-type".to_string(),
575        lemma_type_without_standard_keywords(&data.lemma_type),
576    );
577    let path: Vec<PathSegment> = data.path.iter().map(PathSegment::from).collect();
578    schema.insert("x-lemma-path".to_string(), to_json(&path));
579    schema.insert(
580        "x-lemma-needed-by-rules".to_string(),
581        json!(needed_by_rules),
582    );
583    if let Some(value) = &data.value {
584        schema.insert(
585            "x-lemma-value".to_string(),
586            to_json(&RuleResultValue::from(value)),
587        );
588    }
589    if let Some(suggestion) = &data.suggestion {
590        schema.insert(
591            "x-lemma-suggestion".to_string(),
592            to_json(&RuleResultValue::from(suggestion)),
593        );
594    }
595    JsonValue::Object(schema)
596}
597
598/// Standard keywords for one type. Lists are a JSON array: rows of an instance list are
599/// objects (`$defs` row schema), items of a value list are the element's literal.
600fn type_schema(lemma_type: &DomainLemmaType, rows: &mut RowSchemas) -> Map<String, JsonValue> {
601    let DomainTypeSpecification::List { element, .. } = &lemma_type.specifications else {
602        return scalar_data_schema(lemma_type);
603    };
604    let mut schema = Map::new();
605    let items = match &element.specifications {
606        DomainTypeSpecification::Spec { spec } => rows.reference(spec),
607        DomainTypeSpecification::List { .. } => {
608            unreachable!("BUG: lists do not nest; planning rejects a list element list")
609        }
610        _ => JsonValue::Object(scalar_data_schema(element)),
611    };
612    schema.insert("type".to_string(), json!("array"));
613    schema.insert("items".to_string(), items);
614    schema.insert(
615        "maxItems".to_string(),
616        json!(rows.engine.limits().max_list_rows),
617    );
618    schema
619}
620
621/// A scalar data slot. Measure and ratio data also accept a single-unit object (`{"eur": "84"}`).
622fn scalar_data_schema(lemma_type: &DomainLemmaType) -> Map<String, JsonValue> {
623    let specification = &lemma_type.specifications;
624    let names: Vec<&str> = match specification {
625        DomainTypeSpecification::Measure { units, .. } => {
626            units.iter().map(|unit| unit.name.as_str()).collect()
627        }
628        DomainTypeSpecification::Ratio { units, .. } => {
629            units.iter().map(|unit| unit.name.as_str()).collect()
630        }
631        _ => return scalar_type_schema(lemma_type),
632    };
633    let unit_map = json!({
634        "type": "object",
635        "minProperties": 1,
636        "maxProperties": 1,
637        "propertyNames": {"enum": names},
638        "additionalProperties": {"type": "string"}
639    });
640    let mut schema = Map::new();
641    schema.insert(
642        "oneOf".to_string(),
643        json!([JsonValue::Object(scalar_type_schema(lemma_type)), unit_map]),
644    );
645    publish_option_enum(&mut schema, lemma_type);
646    schema
647}
648
649fn scalar_type_schema(lemma_type: &DomainLemmaType) -> Map<String, JsonValue> {
650    let specification = &lemma_type.specifications;
651    let mut schema = Map::new();
652    match specification {
653        DomainTypeSpecification::Boolean { .. } => {
654            schema.insert(
655                "oneOf".to_string(),
656                json!([
657                    {"type": "boolean"},
658                    {"type": "string", "pattern": BOOLEAN_TEXT_PATTERN}
659                ]),
660            );
661        }
662        DomainTypeSpecification::Text { length, .. } => {
663            schema.insert("type".to_string(), json!("string"));
664            if let Some(length) = length {
665                schema.insert("minLength".to_string(), json!(length));
666                schema.insert("maxLength".to_string(), json!(length));
667            }
668        }
669        DomainTypeSpecification::Number { .. }
670        | DomainTypeSpecification::Measure { .. }
671        | DomainTypeSpecification::Ratio { .. }
672        | DomainTypeSpecification::NumberRange { .. }
673        | DomainTypeSpecification::MeasureRange { .. }
674        | DomainTypeSpecification::RatioRange { .. }
675        | DomainTypeSpecification::DateRange { .. }
676        | DomainTypeSpecification::TimeRange { .. } => {
677            schema.insert("type".to_string(), json!("string"));
678        }
679        DomainTypeSpecification::Date { .. } => {
680            schema.insert(
681                "oneOf".to_string(),
682                json!([
683                    {"type": "string", "format": "date"},
684                    {"type": "string", "format": "date-time"}
685                ]),
686            );
687        }
688        DomainTypeSpecification::Time { .. } => {
689            schema.insert("type".to_string(), json!("string"));
690            schema.insert("format".to_string(), json!("time"));
691        }
692        DomainTypeSpecification::List { .. } => {
693            unreachable!("BUG: list schemas are built by type_schema")
694        }
695        DomainTypeSpecification::Spec { spec } => {
696            unreachable!("BUG: spec '{spec}' is a uses alias, never a Show data entry")
697        }
698        DomainTypeSpecification::Veto { .. } => {
699            unreachable!("BUG: veto sentinel type on a Show data entry")
700        }
701        DomainTypeSpecification::Undetermined => {
702            unreachable!("BUG: undetermined sentinel type on a Show data entry")
703        }
704    }
705    publish_option_enum(&mut schema, lemma_type);
706    schema
707}
708
709fn publish_option_enum(schema: &mut Map<String, JsonValue>, lemma_type: &DomainLemmaType) {
710    if lemma_type.options.values.is_empty() {
711        return;
712    }
713    schema.insert(
714        "enum".to_string(),
715        json!(lemma_type
716            .options
717            .values
718            .iter()
719            .map(|option| lemma_type.display_bound(&option.value))
720            .collect::<Vec<_>>()),
721    );
722}
723
724fn specification_help(specification: &DomainTypeSpecification) -> String {
725    let mut leaf = specification;
726    while let DomainTypeSpecification::List { element, .. } = leaf {
727        leaf = &element.specifications;
728    }
729    match leaf {
730        DomainTypeSpecification::Boolean { help }
731        | DomainTypeSpecification::Measure { help, .. }
732        | DomainTypeSpecification::MeasureRange { help, .. }
733        | DomainTypeSpecification::Number { help, .. }
734        | DomainTypeSpecification::NumberRange { help, .. }
735        | DomainTypeSpecification::Ratio { help, .. }
736        | DomainTypeSpecification::RatioRange { help, .. }
737        | DomainTypeSpecification::Text { help, .. }
738        | DomainTypeSpecification::Date { help, .. }
739        | DomainTypeSpecification::DateRange { help, .. }
740        | DomainTypeSpecification::Time { help, .. }
741        | DomainTypeSpecification::TimeRange { help, .. } => help.clone(),
742        DomainTypeSpecification::Spec { spec } => spec.to_string(),
743        DomainTypeSpecification::List { .. } => {
744            unreachable!("BUG: list chain is unwound before the leaf match")
745        }
746        DomainTypeSpecification::Veto { .. } | DomainTypeSpecification::Undetermined => {
747            unreachable!("BUG: sentinel type on a Show data entry")
748        }
749    }
750}
751
752/// Run-data JSON for a committed value or suggestion: a boolean is a JSON boolean, a value
753/// list an array of element literals, an instance list an array of row objects keyed by
754/// field, everything else the literal string.
755fn rule_result_value_has_reference(value: &DomainRuleResultValue) -> bool {
756    if value.reference.is_some() {
757        return true;
758    }
759    if let Some(fields) = &value.fields {
760        if fields.values().any(rule_result_value_has_reference) {
761            return true;
762        }
763    }
764    if let Some(list) = &value.list {
765        if list.iter().any(rule_result_value_has_reference) {
766            return true;
767        }
768    }
769    false
770}
771
772enum InputLiteralTask<'a> {
773    Visit {
774        value: &'a DomainRuleResultValue,
775        lemma_type: Arc<DomainLemmaType>,
776    },
777    CollectArray {
778        len: usize,
779    },
780    CollectObject {
781        keys: Vec<String>,
782    },
783}
784
785fn input_literal(
786    value: &DomainRuleResultValue,
787    lemma_type: &DomainLemmaType,
788    rows: &RowSchemas,
789) -> JsonValue {
790    let mut tasks = vec![InputLiteralTask::Visit {
791        value,
792        lemma_type: Arc::new(lemma_type.clone()),
793    }];
794    let mut converted: Vec<JsonValue> = Vec::new();
795    while let Some(task) = tasks.pop() {
796        match task {
797            InputLiteralTask::Visit { value, lemma_type } => match &lemma_type.specifications {
798                DomainTypeSpecification::Boolean { .. } => converted.push(JsonValue::Bool(
799                    value
800                        .boolean
801                        .expect("BUG: boolean-typed Show value without boolean"),
802                )),
803                DomainTypeSpecification::List { element, .. } => {
804                    let items = value
805                        .list
806                        .as_ref()
807                        .expect("BUG: list-typed Show value without list");
808                    tasks.push(InputLiteralTask::CollectArray { len: items.len() });
809                    match &element.specifications {
810                        DomainTypeSpecification::Spec { spec } => {
811                            let row_show = rows.engine.show_spec(spec);
812                            for row in items.iter().rev() {
813                                let fields = row
814                                    .fields
815                                    .as_ref()
816                                    .expect("BUG: instance list Show row without fields");
817                                let mut keys = Vec::with_capacity(fields.len());
818                                let mut children = Vec::with_capacity(fields.len());
819                                for (name, field) in fields {
820                                    assert!(
821                                        field.reference.is_none(),
822                                        "BUG: input_literal on Show value with reference field '{name}'"
823                                    );
824                                    let field_type = row_show
825                                        .types
826                                        .get(name)
827                                        .or_else(|| row_show.data.get(name).map(|d| &d.lemma_type))
828                                        .unwrap_or_else(|| {
829                                            panic!("BUG: row field '{name}' missing from Show of '{spec}'")
830                                        });
831                                    keys.push(name.clone());
832                                    children.push((field, Arc::new(field_type.clone())));
833                                }
834                                tasks.push(InputLiteralTask::CollectObject { keys });
835                                for (field, field_type) in children.into_iter().rev() {
836                                    tasks.push(InputLiteralTask::Visit {
837                                        value: field,
838                                        lemma_type: field_type,
839                                    });
840                                }
841                            }
842                        }
843                        _ => {
844                            for item in items.iter().rev() {
845                                tasks.push(InputLiteralTask::Visit {
846                                    value: item,
847                                    lemma_type: Arc::clone(element),
848                                });
849                            }
850                        }
851                    }
852                }
853                _ => converted.push(JsonValue::String(
854                    value
855                        .result
856                        .clone()
857                        .expect("BUG: scalar Show value without result"),
858                )),
859            },
860            InputLiteralTask::CollectArray { len } => {
861                let items = converted.split_off(converted.len() - len);
862                converted.push(JsonValue::Array(items));
863            }
864            InputLiteralTask::CollectObject { keys } => {
865                let children = converted.split_off(converted.len() - keys.len());
866                converted.push(JsonValue::Object(keys.into_iter().zip(children).collect()));
867            }
868        }
869    }
870    converted
871        .pop()
872        .expect("BUG: Show value conversion yields exactly one JSON value")
873}
874
875/// The API `LemmaType` minus what standard keywords already state: `help` (`description`)
876/// and text `options`/`length` (`enum`, `minLength`/`maxLength`).
877fn lemma_type_without_standard_keywords(lemma_type: &DomainLemmaType) -> JsonValue {
878    let mut value = to_json(&LemmaType::from(lemma_type));
879    strip_standard_keywords(&mut value);
880    value
881}
882
883fn strip_standard_keywords(value: &mut JsonValue) {
884    let mut pending = vec![value];
885    while let Some(value) = pending.pop() {
886        let object = value
887            .as_object_mut()
888            .expect("BUG: LemmaType serializes to a JSON object");
889        object.remove("help");
890        if object.get("kind") == Some(&json!("text")) {
891            object.remove("options");
892            object.remove("length");
893        }
894        if let Some(element) = object.get_mut("element") {
895            pending.push(element);
896        }
897    }
898}
899
900/// Row object schemas of instance lists, one `$defs` entry per row spec. A self-list's row
901/// schema refers to itself.
902struct RowSchemas<'a> {
903    engine: &'a Engine,
904    /// Consumer plan whose instance-list [`crate::planning::execution_plan::NeededByRules::fields`]
905    /// feed `x-lemma-row-field-needed-by-rules` (not `$defs`).
906    consumer: Option<&'a ExecutionPlan>,
907    started: BTreeSet<SpecIdentity>,
908    pending: Vec<SpecIdentity>,
909    defs: BTreeMap<String, JsonValue>,
910}
911
912impl<'a> RowSchemas<'a> {
913    fn new(engine: &'a Engine, consumer: Option<&'a ExecutionPlan>) -> Self {
914        Self {
915            engine,
916            consumer,
917            started: BTreeSet::new(),
918            pending: Vec::new(),
919            defs: BTreeMap::new(),
920        }
921    }
922
923    /// Builds every row schema reached from the document, plus the parent-rule map
924    /// for each def. Row schemas that reference further row specs queue them on
925    /// `pending`, so the walk never nests.
926    fn into_defs(mut self) -> (Map<String, JsonValue>, Map<String, JsonValue>) {
927        let mut row_field_needed_by_rules = Map::new();
928        while let Some(identity) = self.pending.pop() {
929            let show = self.engine.show_spec(&identity);
930            let mut schema = Map::new();
931            schema.insert("title".to_string(), json!(format!("{identity} row")));
932            schema.extend(row_object_schema(&show, &mut self));
933            let def_name = row_def_name(&identity);
934            let mut field_map = Map::new();
935            for field in row_property_names(&show) {
936                let needed = self.row_field_needed_by_rules(&identity, &field);
937                if !needed.is_empty() {
938                    field_map.insert(field, json!(needed));
939                }
940            }
941            if !field_map.is_empty() {
942                row_field_needed_by_rules.insert(def_name.clone(), JsonValue::Object(field_map));
943            }
944            self.defs.insert(def_name, JsonValue::Object(schema));
945        }
946        (self.defs.into_iter().collect(), row_field_needed_by_rules)
947    }
948
949    /// Parent Show.rules keys that need `field` on rows of `identity`.
950    fn row_field_needed_by_rules(&self, identity: &SpecIdentity, field: &str) -> Vec<String> {
951        let Some(consumer) = self.consumer else {
952            return Vec::new();
953        };
954        consumer
955            .row_field_needed_by_rules(identity, field)
956            .into_iter()
957            .map(|rule_position| {
958                &consumer
959                    .rules
960                    .get_index(rule_position as usize)
961                    .expect("BUG: needed_by_rules field consumer out of consumer.rules range")
962                    .1
963                    .path
964            })
965            .filter(|rule_path| consumer.show_rules.contains_key(*rule_path))
966            .map(|rule_path| rule_path.input_key())
967            .collect()
968    }
969
970    /// `$ref` to the row schema of `identity`; the schema is queued on first use.
971    fn reference(&mut self, identity: &SpecIdentity) -> JsonValue {
972        let name = row_def_name(identity);
973        if self.started.insert(identity.clone()) {
974            self.pending.push(identity.clone());
975        }
976        json!({ "$ref": format!("#/$defs/{name}") })
977    }
978}
979
980/// `LemmaRow_` plus the row spec identity, injectively escaped to `[A-Za-z0-9_-]` (a valid
981/// OpenAPI component name): `_` becomes `__`, any other character `_<hex code point>_`.
982/// One name per identity, so documents of different specs agree on shared row specs.
983pub fn row_def_name(identity: &SpecIdentity) -> String {
984    let mut name = String::from("LemmaRow_");
985    for c in identity.to_string().chars() {
986        if c.is_ascii_alphanumeric() || c == '-' {
987            name.push(c);
988        } else if c == '_' {
989            name.push_str("__");
990        } else {
991            name.push_str(&format!("_{:x}_", c as u32));
992        }
993    }
994    name
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000    use crate::parsing::ast::Span;
1001    use crate::parsing::source::{Source, SourceType};
1002    use crate::planning::semantics::parse_value_from_string;
1003
1004    const INPUTS: &str = r#"
1005spec inputs
1006data money: measure -> unit eur: 1 -> unit cent: 0.01
1007data price: money -> suggest 10 eur
1008data deposit: 250 cent
1009data rate: ratio -> suggest 21%
1010data bps: ratio -> unit basis_points: 10000 -> unit percent: 100 -> suggest 500 basis_points
1011data fee: 3%
1012data quantity: number -> suggest 12.5
1013data offset: -3
1014data express: boolean -> suggest true
1015data code: text -> option "A" -> option "B" -> suggest "B"
1016data label: text -> length 3
1017data start: date -> suggest 2026-01-01
1018data at: time -> suggest 09:30:00
1019data window: number range -> suggest 1...5
1020data tags: number list
1021  -> with 5, 10
1022rule r_price: price + deposit
1023rule r_rate: rate
1024rule r_bps: bps
1025rule r_fee: fee
1026rule r_quantity: quantity + offset
1027rule r_express: express
1028rule r_code: code
1029rule r_label: label
1030rule r_start: start
1031rule r_at: at
1032rule r_window: window
1033rule r_tags: tags -> sum
1034"#;
1035
1036    fn engine() -> Engine {
1037        let mut engine = Engine::new();
1038        engine
1039            .load([(SourceType::Volatile, INPUTS.to_string())])
1040            .expect("load inputs");
1041        engine
1042    }
1043
1044    fn document() -> (Engine, DomainShow, JsonValue) {
1045        let engine = engine();
1046        let show = engine.show(None, "inputs", None).expect("show inputs");
1047        let document = show_document(&engine, &show);
1048        (engine, show, document)
1049    }
1050
1051    fn source() -> Source {
1052        Source::new(
1053            SourceType::Volatile,
1054            Span {
1055                start: 0,
1056                end: 0,
1057                line: 1,
1058                col: 1,
1059            },
1060        )
1061    }
1062
1063    fn decimal(text: &JsonValue) -> rust_decimal::Decimal {
1064        text.as_str()
1065            .expect("API decimal is a string")
1066            .parse()
1067            .expect("API decimal parses")
1068    }
1069
1070    /// The parsed literal names the same magnitude, in the same unit, as the API value.
1071    fn assert_literal_denotes(
1072        name: &str,
1073        literal: &str,
1074        value: &JsonValue,
1075        specification: &DomainTypeSpecification,
1076    ) {
1077        let parsed = parse_value_from_string(literal, specification, &source())
1078            .unwrap_or_else(|error| panic!("{name}: '{literal}' must parse: {error}"));
1079        assert_parsed_denotes(name, literal, &parsed, value);
1080    }
1081
1082    fn assert_parsed_denotes(
1083        name: &str,
1084        literal: &str,
1085        parsed: &crate::parsing::ast::Value,
1086        value: &JsonValue,
1087    ) {
1088        use crate::parsing::ast::Value;
1089        match parsed {
1090            Value::Number(magnitude) => {
1091                assert_eq!(*magnitude, decimal(&value["number"]), "{name}: '{literal}'");
1092            }
1093            Value::NumberWithUnit(magnitude, unit) => {
1094                let units = value
1095                    .get("measure")
1096                    .or_else(|| value.get("ratio"))
1097                    .unwrap_or_else(|| panic!("{name}: unit map in {value}"));
1098                assert_eq!(
1099                    *magnitude,
1100                    decimal(&units[unit]),
1101                    "{name}: '{literal}' vs {value}"
1102                );
1103            }
1104            Value::Range(left, right) => {
1105                assert_parsed_denotes(name, literal, left, &value["range"]["from"]);
1106                assert_parsed_denotes(name, literal, right, &value["range"]["to"]);
1107            }
1108            other => panic!("{name}: '{literal}' parsed to unexpected {other:?}"),
1109        }
1110    }
1111
1112    #[test]
1113    fn spec_level_keywords() {
1114        let (_, _, document) = document();
1115        assert_eq!(document["$schema"], JSON_SCHEMA_DIALECT);
1116        assert_eq!(document["title"], "inputs");
1117        assert_eq!(document["type"], "object");
1118        assert_eq!(document["additionalProperties"], true);
1119        assert!(document.get("$defs").is_none());
1120        assert_eq!(document["x-lemma-start-line"], 2);
1121        assert!(document["x-lemma-rules"]["r_price"].is_object());
1122    }
1123
1124    #[test]
1125    fn required_is_sorted_needed_inputs_without_committed_value() {
1126        let (_, _, document) = document();
1127        assert_eq!(
1128            document["required"],
1129            json!([
1130                "at", "bps", "code", "express", "label", "price", "quantity", "rate", "start",
1131                "window"
1132            ])
1133        );
1134    }
1135
1136    #[test]
1137    fn standard_keywords_per_type() {
1138        let (_, _, document) = document();
1139        let properties = &document["properties"];
1140        assert_eq!(properties["express"]["oneOf"][0]["type"], "boolean");
1141        assert_eq!(properties["express"]["examples"], json!([true]));
1142        assert_eq!(properties["code"]["type"], "string");
1143        assert_eq!(properties["code"]["enum"], json!(["A", "B"]));
1144        assert_eq!(properties["label"]["minLength"], 3);
1145        assert_eq!(properties["label"]["maxLength"], 3);
1146        assert_eq!(properties["quantity"]["type"], "string");
1147        assert!(properties["quantity"].get("pattern").is_none());
1148        assert_eq!(
1149            properties["price"]["oneOf"][1]["propertyNames"]["enum"],
1150            json!(["eur", "cent"])
1151        );
1152        assert_eq!(
1153            properties["price"]["oneOf"][1]["additionalProperties"],
1154            json!({ "type": "string" })
1155        );
1156        assert!(properties["price"]["oneOf"][0].get("pattern").is_none());
1157        assert_eq!(
1158            properties["rate"]["oneOf"][1]["propertyNames"]["enum"],
1159            json!(["percent", "permille"])
1160        );
1161        assert_eq!(
1162            properties["bps"]["oneOf"][1]["propertyNames"]["enum"],
1163            json!(["percent", "permille", "basis_points"])
1164        );
1165        assert_eq!(
1166            properties["start"]["oneOf"],
1167            json!([
1168                {"type": "string", "format": "date"},
1169                {"type": "string", "format": "date-time"}
1170            ])
1171        );
1172        assert_eq!(properties["at"]["type"], "string");
1173        assert_eq!(properties["at"]["format"], "time");
1174        assert_eq!(properties["window"]["type"], "string");
1175        assert!(properties["window"].get("pattern").is_none());
1176        assert!(properties["window"].get("format").is_none());
1177        let tags = &properties["tags"];
1178        assert_eq!(tags["type"], "array");
1179        assert_eq!(tags["items"], json!({ "type": "string" }));
1180        assert_eq!(
1181            tags["maxItems"],
1182            crate::ResourceLimits::default().max_list_rows
1183        );
1184        assert_eq!(tags["default"], json!(["5", "10"]));
1185    }
1186
1187    #[test]
1188    fn x_lemma_type_omits_what_standard_keywords_state() {
1189        let (_, _, document) = document();
1190        for (name, property) in document["properties"].as_object().expect("properties") {
1191            let lemma_type = &property["x-lemma-type"];
1192            assert!(lemma_type.get("help").is_none(), "{name}: {lemma_type}");
1193            assert!(lemma_type.get("options").is_none(), "{name}: {lemma_type}");
1194            assert!(lemma_type.get("length").is_none(), "{name}: {lemma_type}");
1195            if let Some(element) = lemma_type.get("element") {
1196                assert!(element.get("help").is_none(), "{name}: {element}");
1197            }
1198            assert!(property.get("x-lemma-path").is_some(), "{name}");
1199            assert!(property.get("x-lemma-needed-by-rules").is_some(), "{name}");
1200        }
1201        assert_eq!(
1202            document["properties"]["price"]["x-lemma-type"]["kind"],
1203            "measure"
1204        );
1205        assert!(document["properties"]["price"]["x-lemma-type"]["units"].is_array());
1206    }
1207
1208    fn value_json_has_reference(value: &JsonValue) -> bool {
1209        if value.get("reference").is_some() {
1210            return true;
1211        }
1212        if let Some(fields) = value.get("fields").and_then(|v| v.as_object()) {
1213            if fields.values().any(value_json_has_reference) {
1214                return true;
1215            }
1216        }
1217        if let Some(list) = value.get("list").and_then(|v| v.as_array()) {
1218            if list.iter().any(value_json_has_reference) {
1219                return true;
1220            }
1221        }
1222        false
1223    }
1224
1225    /// The evaluation-data JSON a typed value stands for, derived from the API value alone.
1226    fn evaluate_data_json_of(value: &JsonValue) -> JsonValue {
1227        if let Some(boolean) = value.get("boolean") {
1228            return boolean.clone();
1229        }
1230        if let Some(items) = value.get("list") {
1231            return JsonValue::Array(
1232                items
1233                    .as_array()
1234                    .expect("list")
1235                    .iter()
1236                    .map(evaluate_data_json_of)
1237                    .collect(),
1238            );
1239        }
1240        if let Some(fields) = value.get("fields") {
1241            return JsonValue::Object(
1242                fields
1243                    .as_object()
1244                    .expect("fields")
1245                    .iter()
1246                    .filter(|(_, field)| field.get("reference").is_none())
1247                    .map(|(name, field)| (name.clone(), evaluate_data_json_of(field)))
1248                    .collect(),
1249            );
1250        }
1251        if value.get("reference").is_some() {
1252            panic!("BUG: reference field is not a run-data literal");
1253        }
1254        value["result"].clone()
1255    }
1256
1257    #[test]
1258    fn instance_list_with_references_has_value_but_no_default() {
1259        let mut engine = Engine::new();
1260        engine
1261            .load([(
1262                SourceType::Volatile,
1263                r#"
1264spec processing
1265data type: text
1266data weight: number
1267data enabled: boolean
1268spec line_item
1269data weight: number
1270data finish_a: boolean
1271uses step: processing
1272data processing: step list
1273  -> with weight: weight, type: "finish_a", enabled: finish_a
1274rule cost: processing -> where enabled -> count
1275"#
1276                .to_string(),
1277            )])
1278            .expect("load");
1279        let show = engine.show(None, "line_item", None).expect("show");
1280        let processing = show.data.get("processing").expect("processing");
1281        let value = processing.value.as_ref().expect("value");
1282        assert!(
1283            rule_result_value_has_reference(value),
1284            "expected reference in value: {value:?}"
1285        );
1286        let document = show_document(&engine, &show);
1287        let property = &document["properties"]["processing"];
1288        assert!(property.get("x-lemma-value").is_some(), "{property}");
1289        assert!(
1290            property.get("default").is_none(),
1291            "default must be omitted when fields reference data: {property}"
1292        );
1293    }
1294
1295    #[test]
1296    fn default_and_examples_agree_with_typed_value_and_suggestion() {
1297        let (_, _, document) = document();
1298        let mut checked = 0;
1299        for (name, property) in document["properties"].as_object().expect("properties") {
1300            match (property.get("default"), property.get("x-lemma-value")) {
1301                (Some(default), Some(value)) => {
1302                    assert_eq!(default, &evaluate_data_json_of(value), "{name}");
1303                    checked += 1;
1304                }
1305                (None, Some(value)) => {
1306                    assert!(
1307                        value_json_has_reference(value),
1308                        "{name}: value without default must carry a reference field"
1309                    );
1310                }
1311                (None, None) => {}
1312                other => {
1313                    panic!("{name}: default and x-lemma-value disagree on presence: {other:?}")
1314                }
1315            }
1316            match (property.get("examples"), property.get("x-lemma-suggestion")) {
1317                (Some(examples), Some(suggestion)) => {
1318                    assert_eq!(
1319                        examples,
1320                        &json!([evaluate_data_json_of(suggestion)]),
1321                        "{name}"
1322                    );
1323                    checked += 1;
1324                }
1325                (None, None) => {}
1326                other => panic!(
1327                    "{name}: examples and x-lemma-suggestion disagree on presence: {other:?}"
1328                ),
1329            }
1330        }
1331        assert_eq!(checked, 13);
1332    }
1333
1334    fn is_numeric_literal_specification(specification: &DomainTypeSpecification) -> bool {
1335        matches!(
1336            specification,
1337            DomainTypeSpecification::Number { .. }
1338                | DomainTypeSpecification::Measure { .. }
1339                | DomainTypeSpecification::Ratio { .. }
1340                | DomainTypeSpecification::NumberRange { .. }
1341                | DomainTypeSpecification::MeasureRange { .. }
1342                | DomainTypeSpecification::RatioRange { .. }
1343        )
1344    }
1345
1346    #[test]
1347    fn emitted_string_literals_denote_their_value() {
1348        let (_, show, document) = document();
1349        let mut checked = 0;
1350        for (name, property) in document["properties"].as_object().expect("properties") {
1351            let specification = &show.data[name].lemma_type.specifications;
1352            if !is_numeric_literal_specification(specification) {
1353                continue;
1354            }
1355            let mut literals: Vec<(&str, &JsonValue)> = Vec::new();
1356            if let Some(literal) = property.get("default").and_then(|value| value.as_str()) {
1357                literals.push((literal, &property["x-lemma-value"]));
1358            }
1359            if let Some(literal) = property
1360                .get("examples")
1361                .and_then(|examples| examples[0].as_str())
1362            {
1363                literals.push((literal, &property["x-lemma-suggestion"]));
1364            }
1365            for (literal, value) in literals {
1366                assert_literal_denotes(name, literal, value, specification);
1367                checked += 1;
1368            }
1369        }
1370        assert_eq!(checked, 8);
1371    }
1372
1373    fn identity(repository: Option<&str>, spec: &str) -> SpecIdentity {
1374        SpecIdentity {
1375            repository: repository.map(str::to_string),
1376            spec: spec.to_string(),
1377            effective: crate::parsing::ast::EffectiveDate::Origin,
1378            slice: crate::parsing::ast::EffectiveDate::Origin,
1379        }
1380    }
1381
1382    #[test]
1383    fn row_def_name_is_injective_and_component_safe() {
1384        assert_eq!(row_def_name(&identity(None, "line")), "LemmaRow_line");
1385        assert_eq!(
1386            row_def_name(&identity(None, "tax_bracket")),
1387            "LemmaRow_tax__bracket"
1388        );
1389        let names = [
1390            row_def_name(&identity(Some("@org/repo"), "line")),
1391            row_def_name(&identity(None, "_org_repo_line")),
1392            row_def_name(&identity(None, "line")),
1393        ];
1394        for name in &names {
1395            assert!(
1396                name.chars()
1397                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
1398                "{name}"
1399            );
1400        }
1401        assert_ne!(names[0], names[1]);
1402        assert_ne!(names[0], names[2]);
1403    }
1404}