extern crate std;
use std::prelude::v1::*;
use std::{format, vec};
use std::collections::BTreeMap;
use super::curve::{CurveData, CurveDef, DefinitionsFile};
use super::report::{
GenerationResult, ResourceTotals, TABLE_BYTES_PER_KNOT, TransferReport, assemble_report,
enforce_family_budgets,
};
use super::rustdoc::rustdoc_debug;
use super::transfer::{
FamilyMemberOrigin, MemberStatus, ObservationGuardBehaviorDef, ObservationGuardDef,
ResolvedTransfer, SelectorValue, TransferData, TransferDef, TransferFamilyDef,
};
use crate::{ObservationGuardBehavior, ObservationGuardMetadata};
pub fn generate(
curves_file: &DefinitionsFile,
value_type: &str,
lut_size: usize,
) -> Result<String, String> {
Ok(generate_with_report(curves_file, value_type, lut_size)?.source)
}
pub fn generate_with_report(
curves_file: &DefinitionsFile,
value_type: &str,
lut_size: usize,
) -> Result<GenerationResult, String> {
let resolved = curves_file.resolved_transfers()?;
enforce_minimum_family_budgets(&curves_file.transfer_families)?;
let mut curves: Vec<(&String, &CurveDef)> = curves_file.curves.iter().collect();
curves.sort_by_key(|(name, _)| *name);
let mut transfers: Vec<(&String, &ResolvedTransfer)> = resolved.iter().collect();
transfers.sort_by_key(|(name, _)| *name);
let transfer_defs: Vec<(&String, &TransferDef)> = transfers
.iter()
.map(|(name, resolved)| (*name, &resolved.def))
.collect();
let has_monotonic_curves = curves.iter().any(|(_, def)| def.monotonic);
let has_non_monotonic_curves = curves.iter().any(|(_, def)| !def.monotonic);
let const_names = emitted_const_names(&curves, &transfer_defs)?;
let mut out = String::new();
out.push_str("// Auto-generated by ph-curves-gen. Do not edit.\n\n");
match (has_non_monotonic_curves, has_monotonic_curves) {
(true, true) => out.push_str("use ph_curves::{CurveLut, MonotonicCurveLut};\n\n"),
(true, false) => out.push_str("use ph_curves::CurveLut;\n\n"),
(false, true) => out.push_str("use ph_curves::MonotonicCurveLut;\n\n"),
(false, false) => {}
}
if !resolved.is_empty() {
let any_guard = resolved
.values()
.any(|resolved| resolved.def.observation_guard.is_some());
if any_guard {
out.push_str(
"use ph_curves::{BoundaryBehavior, MonotonicDirection, ObservationGuardBehavior, \
ObservationGuardMetadata, PiecewiseLinearTransfer, TransferMetadata};\n\n",
);
} else {
out.push_str(
"use ph_curves::{BoundaryBehavior, MonotonicDirection, ObservationGuardMetadata, \
PiecewiseLinearTransfer, TransferMetadata};\n\n",
);
}
}
if has_non_monotonic_curves {
out.push_str(&format!(
"type Lut = CurveLut<{value_type}, {value_type}, {lut_size}>;\n",
));
}
if has_monotonic_curves {
out.push_str(&format!(
"type MonoLut = MonotonicCurveLut<{value_type}, {value_type}, {lut_size}>;\n",
));
}
if has_non_monotonic_curves || has_monotonic_curves {
out.push('\n');
}
for (name, def) in &curves {
let data = super::curve::build(name, def, lut_size)?;
let const_name = &const_names[*name];
emit_curve(&mut out, name, const_name, def, &data, value_type, lut_size);
}
let mut transfer_reports = Vec::new();
let mut running_family_totals: BTreeMap<String, ResourceTotals> = BTreeMap::new();
for (name, resolved) in &transfers {
let overlay = curves_file.overlays.get(*name);
let effective = overlay
.map(|overlay| {
overlay
.effective_def(&resolved.def)
.map_err(|error| format!("transfer `{name}`: {error}"))
})
.transpose()?;
let def = effective.as_ref().unwrap_or(&resolved.def);
let data = if let Some(overlay) = overlay {
super::transfer::build_with_source(name, def, Some(overlay.source()))?
} else {
super::transfer::build(name, def)?
};
let const_name = &const_names[*name];
let report = transfer_report(name, const_name, def, &data, resolved.origin.as_ref());
if let Some(origin) = resolved.origin.as_ref() {
let family = curves_file
.transfer_families
.get(&origin.family)
.ok_or_else(|| {
format!(
"transfer family `{}`: emitted member is missing its family definition",
origin.family
)
})?;
let totals = running_family_totals
.entry(origin.family.clone())
.or_default();
include_running_family_total(&origin.family, totals, &report)?;
enforce_running_family_budget(&origin.family, family, *totals)?;
}
transfer_reports.push(report);
emit_transfer(
&mut out,
name,
const_name,
def,
&data,
resolved.origin.as_ref(),
);
}
let report = assemble_report(
transfer_reports,
&curves_file.transfer_families,
&curves_file.gaps,
)?;
enforce_family_budgets(&curves_file.transfer_families, &report)?;
out.truncate(out.trim_end_matches('\n').len());
out.push('\n');
Ok(GenerationResult {
source: out,
report,
})
}
fn enforce_minimum_family_budgets(
families: &BTreeMap<String, TransferFamilyDef>,
) -> Result<(), String> {
for (family_name, family) in families {
let emitted_member_count = family
.members
.iter()
.filter(|member| member.status == MemberStatus::Emit)
.count();
let minimum_knots = emitted_member_count.checked_mul(2).ok_or_else(|| {
format!("transfer family `{family_name}`: emitted-member count exceeds usize")
})?;
if let Some(limit) = family.max_total_knots
&& minimum_knots > limit
{
return Err(format!(
"transfer family `{family_name}`: max_total_knots={limit} exceeded: \
{minimum_knots} knots across {emitted_member_count} emitted members \
(minimum possible before fitting)"
));
}
let minimum_table_bytes =
minimum_knots
.checked_mul(TABLE_BYTES_PER_KNOT)
.ok_or_else(|| {
format!("transfer family `{family_name}`: minimum table payload exceeds usize")
})?;
if let Some(limit) = family.max_table_bytes
&& minimum_table_bytes > limit
{
return Err(format!(
"transfer family `{family_name}`: max_table_bytes={limit} exceeded: \
{minimum_table_bytes} bytes array payload across \
{emitted_member_count} emitted members (minimum possible before fitting)"
));
}
}
Ok(())
}
fn include_running_family_total(
family_name: &str,
totals: &mut ResourceTotals,
transfer: &TransferReport,
) -> Result<(), String> {
totals.member_count = totals.member_count.checked_add(1).ok_or_else(|| {
format!("transfer family `{family_name}`: emitted-member total exceeds usize")
})?;
totals.knot_count = totals
.knot_count
.checked_add(transfer.knot_count)
.ok_or_else(|| {
format!("transfer family `{family_name}`: aggregate knot total exceeds usize")
})?;
totals.table_bytes = totals
.table_bytes
.checked_add(transfer.table_bytes)
.ok_or_else(|| {
format!("transfer family `{family_name}`: aggregate table payload exceeds usize")
})?;
Ok(())
}
fn enforce_running_family_budget(
family_name: &str,
family: &super::transfer::TransferFamilyDef,
totals: ResourceTotals,
) -> Result<(), String> {
if let Some(limit) = family.max_total_knots
&& totals.knot_count > limit
{
return Err(format!(
"transfer family `{family_name}`: max_total_knots={limit} exceeded: {} knots across {} emitted members",
totals.knot_count, totals.member_count
));
}
if let Some(limit) = family.max_table_bytes
&& totals.table_bytes > limit
{
return Err(format!(
"transfer family `{family_name}`: max_table_bytes={limit} exceeded: {} bytes array payload across {} emitted members",
totals.table_bytes, totals.member_count
));
}
Ok(())
}
fn transfer_report(
name: &str,
const_name: &str,
def: &TransferDef,
data: &TransferData,
origin: Option<&FamilyMemberOrigin>,
) -> TransferReport {
let knot_count = data.inputs.len();
let domain_min = data.inputs[0];
let domain_max = *data.inputs.last().expect("fitter requires two knots");
let (range_min, range_max) = output_range(&data.outputs);
TransferReport {
family: origin.map(|origin| origin.family.clone()),
selectors: origin
.map(|origin| origin.selectors.clone())
.unwrap_or_default(),
table_name: name.to_string(),
symbol: const_name.to_string(),
metadata_symbol: format!("{const_name}_METADATA"),
observation_guard_symbol: format!("{const_name}_OBSERVATION_GUARD"),
domain_min,
domain_max,
range_min,
range_max,
requested_max_error: def.max_interpolation_error,
achieved_max_error: data.achieved_max_error,
worst_case_input: data.worst_case_input,
knot_count,
table_bytes: knot_count * TABLE_BYTES_PER_KNOT,
generation_path: data.generation_path,
provenance: data.provenance.clone(),
observation_guard_provenance: data.guard_provenance.clone(),
policy: def.policy(),
observation_guard: def
.observation_guard
.as_ref()
.map(observation_guard_metadata),
}
}
fn output_range(outputs: &[i32]) -> (i32, i32) {
let first = outputs[0];
let last = *outputs.last().expect("fitter requires two knots");
if first <= last {
(first, last)
} else {
(last, first)
}
}
fn observation_guard_metadata(guard: &ObservationGuardDef) -> ObservationGuardMetadata {
ObservationGuardMetadata {
code: guard.code,
behavior: match guard.behavior {
ObservationGuardBehaviorDef::Error => ObservationGuardBehavior::Error,
ObservationGuardBehaviorDef::Clamp => ObservationGuardBehavior::Clamp,
},
}
}
fn emit_curve(
out: &mut String,
name: &str,
const_name: &str,
def: &CurveDef,
data: &CurveData,
value_type: &str,
lut_size: usize,
) {
let name_doc = rustdoc_debug(name);
out.push_str(&format!(
"static {const_name}_FWD: [{vt}; {lut_size}] = {arr};\n",
vt = value_type,
arr = format_array(&data.fwd),
));
if let Some(inv) = &data.inv {
out.push_str(&format!(
"static {const_name}_INV: [{vt}; {lut_size}] = {arr};\n",
vt = value_type,
arr = format_array(inv),
));
}
if def.monotonic {
out.push_str(&format!(
"/// {name_doc} \u{2014} monotonic curve.\n\
pub const {const_name}: MonoLut = \
MonoLut::new(&{const_name}_FWD, &{const_name}_INV);\n\n",
));
} else {
out.push_str(&format!(
"/// {name_doc} \u{2014} curve (non-monotonic).\n\
pub const {const_name}: Lut = \
Lut::new(&{const_name}_FWD, None);\n\n",
));
}
}
fn emit_transfer(
out: &mut String,
name: &str,
const_name: &str,
def: &TransferDef,
data: &TransferData,
origin: Option<&FamilyMemberOrigin>,
) {
let knot_count = data.inputs.len();
let direction = match data.direction {
crate::MonotonicDirection::Increasing => "MonotonicDirection::Increasing",
crate::MonotonicDirection::Decreasing => "MonotonicDirection::Decreasing",
};
let domain_min = data.inputs[0];
let domain_max = *data.inputs.last().unwrap();
let name_doc = rustdoc_debug(name);
let representation_doc = rustdoc_debug(&data.representation);
let input_unit_doc = rustdoc_debug(&def.input_unit);
let output_unit_doc = rustdoc_debug(&def.output_unit);
out.push_str(&format!(
"#[rustfmt::skip]\n\
static {const_name}_INPUTS: [u16; {knot_count}] = {inputs};\n",
inputs = format_u16_array(&data.inputs),
));
out.push_str(&format!(
"#[rustfmt::skip]\n\
static {const_name}_OUTPUTS: [i32; {knot_count}] = {outputs};\n",
outputs = format_i32_array(&data.outputs),
));
let guard_docs = match def.observation_guard.as_ref() {
Some(guard) => {
let behavior = match guard.behavior {
super::transfer::ObservationGuardBehaviorDef::Error => "Error",
super::transfer::ObservationGuardBehaviorDef::Clamp => "Clamp",
};
let classification = match &data.guard_provenance {
Some(citation) => format!(
"/// Classification of this code as saturation is cited from {} and applied as declared policy, not inferred from the integer value.\n",
citation.rustdoc_clause()
),
None => "/// Classification of this code as saturation is declared consumer/device policy, not inferred from the integer value.\n".into(),
};
format!(
"/// Observation guard: code {} with {behavior} behavior.\n{classification}",
guard.code
)
}
None => "/// Observation guard: none.\n".into(),
};
let construction = match def.observation_guard.as_ref() {
Some(guard) => format!(
"PiecewiseLinearTransfer::new(&{const_name}_INPUTS, &{const_name}_OUTPUTS, {direction})\n\
.with_boundaries({below}, {above})\n\
.with_observation_guard({code}, {behavior});\n",
below = def.below.rust_name(),
above = def.above.rust_name(),
code = guard.code,
behavior = guard.behavior.rust_name(),
),
None => format!(
"PiecewiseLinearTransfer::new(&{const_name}_INPUTS, &{const_name}_OUTPUTS, {direction})\n\
.with_boundaries({below}, {above});\n",
below = def.below.rust_name(),
above = def.above.rust_name(),
),
};
let provenance_docs = match &data.provenance {
Some(citation) => format!("/// Source provenance: {}.\n", citation.rustdoc_clause()),
None => "/// Source provenance: none declared.\n".into(),
};
let policy_docs = format!(
"/// Generation policy: {}.\n",
def.policy().rustdoc_clause()
);
let identity_docs = family_identity_docs(origin);
out.push_str(&format!(
"{identity_docs}\
/// {name_doc} sparse physical transfer function.\n\
///\n\
{provenance_docs}\
/// Representation: {representation_doc}.\n\
{policy_docs}\
/// Domain: {domain_min}..={domain_max} {input_unit_doc}; output: {output_unit_doc} x {output_scale}.\n\
/// Knots: {knot_count} ({payload} bytes array payload).\n\
/// Exhaustive numerical error: requested <= {requested}, achieved {achieved:.6} output quanta\n\
/// (conservative metadata bound {achieved_bound}) at input {worst}.\n\
/// This is table/quantization error against the configured ideal source, not total sensor accuracy.\n\
{guard_docs}\
#[rustfmt::skip]\n\
pub const {const_name}: PiecewiseLinearTransfer<{knot_count}> =\n\
{construction}",
output_scale = def.output_scale,
payload = knot_count * TABLE_BYTES_PER_KNOT,
requested = def.max_interpolation_error,
achieved = data.achieved_max_error_exact,
achieved_bound = data.achieved_max_error,
worst = data.worst_case_input,
));
let (range_min, range_max) = output_range(&data.outputs);
let flat_segment_count = data
.outputs
.windows(2)
.filter(|pair| pair[0] == pair[1])
.count();
let strictly_monotonic = flat_segment_count == 0;
let achieved_max_inverse_code_error =
super::transfer::measure_inverse_code_error(&data.inputs, &data.outputs, data.direction);
out.push_str(&format!(
"/// Metadata for [`{const_name}`].\n\
pub const {const_name}_METADATA: TransferMetadata = TransferMetadata {{\n\
\x20 input_unit: {input_unit:?},\n\
\x20 output_unit: {output_unit:?},\n\
\x20 output_scale: {output_scale},\n\
\x20 domain_min: {domain_min},\n\
\x20 domain_max: {domain_max},\n\
\x20 range_min: {range_min},\n\
\x20 range_max: {range_max},\n\
\x20 direction: {direction},\n\
\x20 knot_count: {knot_count},\n\
\x20 strictly_monotonic: {strictly_monotonic},\n\
\x20 flat_segment_count: {flat_segment_count},\n\
\x20 requested_max_error: {requested},\n\
\x20 achieved_max_error: {achieved},\n\
\x20 worst_case_input: {worst},\n\
\x20 achieved_max_inverse_code_error: {inverse_error},\n\
}};\n",
input_unit = def.input_unit,
output_unit = def.output_unit,
output_scale = def.output_scale,
requested = def.max_interpolation_error,
achieved = data.achieved_max_error,
worst = data.worst_case_input,
inverse_error = achieved_max_inverse_code_error,
));
let guard_metadata = match def.observation_guard.as_ref() {
Some(guard) => format!(
"Some(ObservationGuardMetadata {{\n\
\x20 code: {},\n\
\x20 behavior: {},\n\
}})",
guard.code,
guard.behavior.rust_name()
),
None => "None".into(),
};
let guard_companion_docs = match &data.guard_provenance {
Some(citation) => format!(
"/// Optional observation-code guard for [`{const_name}`].\n\
///\n\
/// Classification of a code as saturation is cited from {} and applied as declared policy,\n\
/// not inferred from the integer value. The runtime getter and this constant agree.\n",
citation.rustdoc_clause()
),
None => format!(
"/// Optional observation-code guard for [`{const_name}`].\n\
///\n\
/// Classification of a code as saturation is declared consumer/device policy,\n\
/// not inferred from the integer value. The runtime getter and this constant agree.\n"
),
};
out.push_str(&format!(
"{guard_companion_docs}\
pub const {const_name}_OBSERVATION_GUARD: Option<ObservationGuardMetadata> = {guard_metadata};\n\n"
));
}
fn family_identity_docs(origin: Option<&FamilyMemberOrigin>) -> String {
let Some(origin) = origin else {
return String::new();
};
let family_doc = rustdoc_debug(&origin.family);
let selectors = origin
.selectors
.iter()
.map(|(key, value)| {
let key_doc = rustdoc_debug(key);
let value_doc = match value {
SelectorValue::String(text) => rustdoc_debug(text),
SelectorValue::Integer(int) => int.to_string(),
};
format!("{key_doc} = {value_doc}")
})
.collect::<Vec<_>>()
.join(", ");
format!("/// Family: {family_doc}.\n/// Selectors: {selectors}.\n")
}
pub(crate) fn emitted_const_names(
curves: &[(&String, &CurveDef)],
transfers: &[(&String, &TransferDef)],
) -> Result<BTreeMap<String, String>, String> {
let mut names = BTreeMap::new();
let mut base_sources = BTreeMap::new();
for name in curves
.iter()
.map(|(name, _)| *name)
.chain(transfers.iter().map(|(name, _)| *name))
{
let const_name = to_const_name(name)?;
if let Some(previous) = base_sources.insert(const_name.clone(), name.as_str()) {
return Err(format!(
"names `{previous}` and `{name}` both normalize to Rust identifier \
`{const_name}`; rename one entry"
));
}
names.insert(name.clone(), const_name);
}
let mut emitted_sources = BTreeMap::new();
for (name, def) in curves {
let const_name = &names[*name];
let mut identifiers = vec![const_name.clone(), format!("{const_name}_FWD")];
if def.monotonic {
identifiers.push(format!("{const_name}_INV"));
}
reserve_identifiers(name, identifiers, &mut emitted_sources)?;
}
for (name, _) in transfers {
let const_name = &names[*name];
reserve_identifiers(
name,
[
const_name.clone(),
format!("{const_name}_INPUTS"),
format!("{const_name}_OUTPUTS"),
format!("{const_name}_METADATA"),
format!("{const_name}_OBSERVATION_GUARD"),
],
&mut emitted_sources,
)?;
}
Ok(names)
}
fn reserve_identifiers<'a>(
name: &'a str,
identifiers: impl IntoIterator<Item = String>,
emitted_sources: &mut BTreeMap<String, &'a str>,
) -> Result<(), String> {
for identifier in identifiers {
if let Some(previous) = emitted_sources.insert(identifier.clone(), name) {
return Err(format!(
"names `{previous}` and `{name}` generate duplicate Rust identifier \
`{identifier}`; rename one entry"
));
}
}
Ok(())
}
pub(crate) fn to_const_name(name: &str) -> Result<String, String> {
let mut out = String::new();
let mut prev_underscore = false;
let mut has_alphanumeric = false;
for ch in name.chars() {
let next = if ch.is_ascii_alphanumeric() {
has_alphanumeric = true;
ch.to_ascii_uppercase()
} else {
'_'
};
if next == '_' {
if !prev_underscore {
out.push('_');
prev_underscore = true;
}
} else {
out.push(next);
prev_underscore = false;
}
}
if !has_alphanumeric {
return Err(format!(
"name `{name}` cannot be normalized to a Rust identifier; \
include at least one ASCII letter or digit"
));
}
if out.as_bytes()[0].is_ascii_digit() {
out.insert_str(0, "CURVE_");
}
Ok(out)
}
fn format_array(values: &[u32]) -> String {
let mut out = String::from("[");
for (idx, value) in values.iter().enumerate() {
if idx > 0 {
out.push_str(", ");
}
out.push_str(&value.to_string());
}
out.push(']');
out
}
fn format_u16_array(values: &[u16]) -> String {
format_values(values)
}
fn format_i32_array(values: &[i32]) -> String {
format_values(values)
}
fn format_values<T: core::fmt::Display>(values: &[T]) -> String {
let mut out = String::from("[");
for (idx, value) in values.iter().enumerate() {
if idx > 0 {
out.push_str(", ");
}
out.push_str(&value.to_string());
}
out.push(']');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::r#gen::transfer::{BoundaryDef, PhysicalPoint, TransferDef};
use std::collections::BTreeMap;
#[test]
fn const_name_simple() {
assert_eq!(to_const_name("linear").unwrap(), "LINEAR");
}
#[test]
fn const_name_with_underscores() {
assert_eq!(to_const_name("ease_in_quad").unwrap(), "EASE_IN_QUAD");
}
#[test]
fn const_name_collapses_multiple_non_alnum() {
assert_eq!(to_const_name("a--b").unwrap(), "A_B");
}
#[test]
fn const_name_rejects_empty() {
let error = to_const_name("").unwrap_err();
assert!(error.contains("cannot be normalized"));
assert!(error.contains("ASCII letter or digit"));
}
#[test]
fn const_name_rejects_special_chars_only() {
let error = to_const_name("---").unwrap_err();
assert!(error.contains("cannot be normalized"));
assert!(error.contains("Rust identifier"));
}
#[test]
fn const_name_prefixes_leading_digit() {
assert_eq!(to_const_name("2bit").unwrap(), "CURVE_2BIT");
assert_eq!(to_const_name("123 curve").unwrap(), "CURVE_123_CURVE");
}
#[test]
fn format_array_small() {
assert_eq!(format_array(&[0, 1, 2, 3]), "[0, 1, 2, 3]");
}
#[test]
fn format_array_single() {
assert_eq!(format_array(&[42]), "[42]");
}
#[test]
fn format_array_empty() {
assert_eq!(format_array(&[]), "[]");
}
#[test]
fn rustdoc_debug_fence_exceeds_every_backtick_run() {
for run_length in 1..=64 {
let value = format!("https://example.invalid/{}suffix", "`".repeat(run_length));
let rendered = rustdoc_debug(&value);
let fence = "`".repeat(run_length + 1);
assert!(rendered.starts_with(&format!("{fence}\"")), "{rendered}");
assert!(rendered.ends_with(&format!("\"{fence}")), "{rendered}");
}
}
#[test]
fn generate_monotonic_curve() {
let mut curves = BTreeMap::new();
curves.insert(
"linear".to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
let cf = DefinitionsFile {
curves,
transfers: BTreeMap::new(),
..Default::default()
};
let out = generate(&cf, "u8", 256).unwrap();
assert!(out.contains("AUTO_LINEAR_FWD") || out.contains("LINEAR_FWD"));
assert!(out.contains("LINEAR_INV"));
assert!(out.contains("MonoLut"));
assert!(out.contains("use ph_curves::MonotonicCurveLut;"), "{out}");
assert!(!out.contains("type Lut ="), "{out}");
assert!(out.contains(r#"/// "linear" — monotonic curve."#));
}
#[test]
fn generate_non_monotonic_curve() {
let mut curves = BTreeMap::new();
curves.insert(
"wave".to_string(),
CurveDef {
builtin: None,
formula: None,
points: Some(vec![[0, 0], [64, 200], [192, 50], [255, 255]]),
monotonic: false,
},
);
let cf = DefinitionsFile {
curves,
transfers: BTreeMap::new(),
..Default::default()
};
let out = generate(&cf, "u8", 256).unwrap();
assert!(out.contains("WAVE_FWD"));
assert!(!out.contains("WAVE_INV"));
assert!(out.contains("Lut::new"));
assert!(out.contains("use ph_curves::CurveLut;"), "{out}");
assert!(!out.contains("type MonoLut ="), "{out}");
}
#[test]
fn single_kind_curve_documents_build_with_denied_warnings() {
fn definition(name: &str, monotonic: bool) -> DefinitionsFile {
let mut curves = BTreeMap::new();
curves.insert(
name.to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic,
},
);
DefinitionsFile {
curves,
transfers: BTreeMap::new(),
..Default::default()
}
}
let monotonic = generate(&definition("monotonic_only", true), "u8", 4).unwrap();
let non_monotonic = generate(&definition("non_monotonic_only", false), "u8", 4).unwrap();
assert!(monotonic.contains("type MonoLut ="), "{monotonic}");
assert!(!monotonic.contains("type Lut ="), "{monotonic}");
assert!(non_monotonic.contains("type Lut ="), "{non_monotonic}");
assert!(!non_monotonic.contains("type MonoLut ="), "{non_monotonic}");
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory = std::env::temp_dir().join(format!(
"ph_curves_generated_aliases_{}_{}",
std::process::id(),
unique
));
let source_directory = directory.join("src");
std::fs::create_dir_all(&source_directory).unwrap();
let manifest = format!(
"[package]\nname = \"generated-alias-probe\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[dependencies]\nph-curves = {{ path = {:?} }}\n",
env!("CARGO_MANIFEST_DIR")
);
std::fs::write(directory.join("Cargo.toml"), manifest).unwrap();
for generated in [&monotonic, &non_monotonic] {
std::fs::write(
source_directory.join("lib.rs"),
format!("#![no_std]\n{generated}"),
)
.unwrap();
let output = std::process::Command::new(
std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
)
.arg("check")
.arg("--quiet")
.arg("--offline")
.arg("--manifest-path")
.arg(directory.join("Cargo.toml"))
.env("RUSTFLAGS", "-Dwarnings")
.env("CARGO_TARGET_DIR", directory.join("target"))
.output()
.unwrap();
assert!(
output.status.success(),
"generated crate failed -D warnings:\n{}\n{}\n{generated}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
std::fs::remove_dir_all(&directory).unwrap();
}
#[test]
fn generate_u16_lut() {
let mut curves = BTreeMap::new();
curves.insert(
"lin".to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
let cf = DefinitionsFile {
curves,
transfers: BTreeMap::new(),
..Default::default()
};
let out = generate(&cf, "u16", 10).unwrap();
assert!(out.contains("u16"));
assert!(out.contains("10"));
}
#[test]
fn generate_rejects_normalization_collisions() {
let mut curves = BTreeMap::new();
for name in ["ease-in", "ease_in"] {
curves.insert(
name.to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
}
let error = generate(
&DefinitionsFile {
curves,
transfers: BTreeMap::new(),
..Default::default()
},
"u8",
256,
)
.unwrap_err();
assert!(error.contains("both normalize"));
assert!(error.contains("`EASE_IN`"));
}
#[test]
fn generate_rejects_curve_companion_collisions() {
let mut curves = BTreeMap::new();
for name in ["linear", "linear_fwd"] {
curves.insert(
name.to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
}
let error = generate(
&DefinitionsFile {
curves,
transfers: BTreeMap::new(),
..Default::default()
},
"u8",
256,
)
.unwrap_err();
assert!(error.contains("duplicate Rust identifier"));
assert!(error.contains("`LINEAR_FWD`"));
}
#[test]
fn generate_escapes_curve_names_in_doc_comments() {
let mut curves = BTreeMap::new();
curves.insert(
"line\nbreak".to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
let out = generate(
&DefinitionsFile {
curves,
transfers: BTreeMap::new(),
..Default::default()
},
"u8",
256,
)
.unwrap();
assert!(out.contains(r#"/// "line\nbreak" — monotonic curve."#));
assert!(!out.contains("/// \"line\nbreak"));
}
#[test]
fn generate_rejects_curve_transfer_name_collisions() {
let mut curves = BTreeMap::new();
curves.insert(
"sensor".to_string(),
CurveDef {
builtin: Some("linear".into()),
formula: None,
points: None,
monotonic: true,
},
);
let mut transfers = BTreeMap::new();
transfers.insert(
"sensor".to_string(),
TransferDef {
input_unit: "adc_code".into(),
output_unit: "volt".into(),
output_scale: 1000,
max_interpolation_error: 1,
max_knots: 256,
below: BoundaryDef::Error,
above: BoundaryDef::Error,
observation_guard: None,
provenance: None,
resolved_guard_provenance: None,
points: Some(vec![
PhysicalPoint {
input: 0,
output: 0.0,
},
PhysicalPoint {
input: 10,
output: 1.0,
},
]),
formula: None,
model: None,
domain: None,
output_range: None,
},
);
let error = generate(
&DefinitionsFile {
curves,
transfers,
..Default::default()
},
"u8",
256,
)
.unwrap_err();
assert!(error.contains("both normalize"));
assert!(error.contains("`SENSOR`"));
}
#[test]
fn generate_rejects_transfer_companion_collisions() {
let mut transfers = BTreeMap::new();
for name in ["ntc", "ntc_inputs"] {
transfers.insert(
name.to_string(),
TransferDef {
input_unit: "adc_code".into(),
output_unit: "volt".into(),
output_scale: 1000,
max_interpolation_error: 1,
max_knots: 256,
below: BoundaryDef::Error,
above: BoundaryDef::Error,
observation_guard: None,
provenance: None,
resolved_guard_provenance: None,
points: Some(vec![
PhysicalPoint {
input: 0,
output: 0.0,
},
PhysicalPoint {
input: 10,
output: 1.0,
},
]),
formula: None,
model: None,
domain: None,
output_range: None,
},
);
}
let error = generate(
&DefinitionsFile {
curves: BTreeMap::new(),
transfers,
..Default::default()
},
"u8",
256,
)
.unwrap_err();
assert!(error.contains("duplicate Rust identifier"));
assert!(error.contains("`NTC_INPUTS`"));
}
#[test]
fn generate_escapes_transfer_fields_in_doc_comments() {
let mut transfers = BTreeMap::new();
transfers.insert(
"line\nbreak".to_string(),
TransferDef {
input_unit: "adc\ncode".into(),
output_unit: "volt\nunit".into(),
output_scale: 1000,
max_interpolation_error: 1,
max_knots: 256,
below: BoundaryDef::Error,
above: BoundaryDef::Error,
observation_guard: None,
provenance: None,
resolved_guard_provenance: None,
formula: Some("x\n* 0.5".into()),
points: None,
model: None,
domain: Some([0, 10]),
output_range: None,
},
);
let out = generate(
&DefinitionsFile {
curves: BTreeMap::new(),
transfers,
..Default::default()
},
"u8",
256,
)
.unwrap();
assert!(out.contains(r#"/// "line\nbreak" sparse physical transfer function."#));
assert!(out.contains(r#"Representation: "formula y = x\n\* 0.5"."#));
assert!(out.contains("Source provenance: none declared."));
assert!(out.contains(r#""adc\ncode""#));
assert!(out.contains(r#""volt\nunit""#));
assert!(!out.contains("/// \"line\nbreak"));
}
#[test]
fn reference_ntc_matches_golden_output() {
let definition: DefinitionsFile =
toml::from_str(include_str!("../../assets/transfers.toml")).unwrap();
let output = generate(&definition, "u8", 256).unwrap();
let expected = include_str!("../../tests/fixtures/ntc_generated.rs").replace("\r\n", "\n");
assert_eq!(output, expected);
assert!(output.ends_with('\n'));
assert!(!output.ends_with("\n\n"));
assert!(!output.contains("f32"));
assert!(!output.contains("f64"));
}
#[test]
fn custom_transfer_examples_generate_integer_firmware_code() {
let definition: DefinitionsFile =
toml::from_str(include_str!("../../assets/custom-transfers.toml")).unwrap();
let output = generate(&definition, "u8", 256).unwrap();
assert!(output.contains("pub const PRESSURE_100KPA"));
assert!(output.contains("pub const TANK_LEVEL"));
assert!(output.contains("PiecewiseLinearTransfer"));
assert!(!output.contains("f32"));
assert!(!output.contains("f64"));
}
#[test]
fn observation_guard_examples_match_golden_output() {
let definition: DefinitionsFile =
toml::from_str(include_str!("../../assets/observation-guards.toml")).unwrap();
let output = generate(&definition, "u8", 256).unwrap();
let expected = include_str!("../../tests/fixtures/observation_guards_generated.rs")
.replace("\r\n", "\n");
assert_eq!(output, expected);
assert!(output.ends_with('\n'));
assert!(!output.ends_with("\n\n"));
assert!(!output.contains("f32"));
assert!(!output.contains("f64"));
}
#[test]
fn family_acceptance_examples_match_golden_output() {
let definition: DefinitionsFile =
toml::from_str(include_str!("../../assets/family-acceptance.toml")).unwrap();
let output = generate(&definition, "u8", 256).unwrap();
let expected = include_str!("../../tests/fixtures/family_acceptance_generated.rs")
.replace("\r\n", "\n");
assert_eq!(output, expected);
assert!(output.ends_with('\n'));
assert!(!output.ends_with("\n\n"));
assert!(output.contains("CurveLut"));
assert!(output.contains("pub const LINEAR"));
assert!(output.contains("PiecewiseLinearTransfer"));
assert!(output.contains("pub const FRONT_END_LOW_DC"));
assert!(output.contains("pub const GUARDED_IDENTITY"));
assert!(!output.contains("f32"));
assert!(!output.contains("f64"));
}
#[test]
fn unguarded_transfers_also_reserve_observation_guard_companion_names() {
let mut transfers = BTreeMap::new();
for name in ["ntc", "ntc_observation_guard"] {
transfers.insert(
name.to_string(),
TransferDef {
input_unit: "adc_code".into(),
output_unit: "volt".into(),
output_scale: 1000,
max_interpolation_error: 1,
max_knots: 256,
below: BoundaryDef::Error,
above: BoundaryDef::Error,
observation_guard: None,
provenance: None,
resolved_guard_provenance: None,
points: Some(vec![
PhysicalPoint {
input: 0,
output: 0.0,
},
PhysicalPoint {
input: 10,
output: 1.0,
},
]),
formula: None,
model: None,
domain: None,
output_range: None,
},
);
}
let error = generate(
&DefinitionsFile {
curves: BTreeMap::new(),
transfers,
..Default::default()
},
"u8",
256,
)
.unwrap_err();
assert!(error.contains("duplicate Rust identifier"));
assert!(error.contains("`NTC_OBSERVATION_GUARD`"));
}
fn family_header() -> String {
family_header_with_axes(r#"{ gain = ["div4"], integration_time_ms = [100] }"#)
}
fn family_header_with_axes(axes: &str) -> String {
format!(
r#"
[transfers]
requires = ["transfer_families_v1"]
[transfer_families.als]
provenance = {{ identity = "test fixture" }}
input_unit = "count"
output_unit = "unit"
output_scale = 1000
max_interpolation_error = 50
max_knots = 64
below = "error"
above = "error"
formula = "x"
selector_axes = {axes}
"#
)
}
fn member_toml(gain: &str, it: i64, status: &str) -> String {
let reason = if status == "emit" {
String::new()
} else {
"reason = \"fixture description-only member\"\n".into()
};
format!(
"[[transfer_families.als.members]]\n\
selectors = {{ gain = \"{gain}\", integration_time_ms = {it} }}\n\
status = \"{status}\"\n\
{reason}\
applicability = {{ observation = [1, 10] }}\n"
)
}
fn twenty_four_member_family_toml() -> String {
let mut toml = family_header_with_axes(
r#"{ gain = ["x1", "x2", "div4", "div8"], integration_time_ms = [25, 50, 100, 200, 400, 800] }"#,
);
for gain in ["x1", "x2", "div4", "div8"] {
let status = if gain == "div4" || gain == "div8" {
"emit"
} else {
"forbidden"
};
for it in [25_i64, 50, 100, 200, 400, 800] {
toml.push_str(&member_toml(gain, it, status));
}
}
toml.push_str(
"[gaps.white_channel]\nstatus = \"undefined\"\nreason = \"counts only\"\n\
[gaps.ir_lux_optimization]\nstatus = \"undefined\"\nreason = \"no conversion\"\n",
);
toml
}
#[test]
fn mixed_curve_and_family_generate_through_their_own_paths() {
let mut toml = String::from("[curves.linear]\nbuiltin = \"linear\"\n");
toml.push_str(&family_header());
toml.push_str(&member_toml("div4", 100, "emit"));
let definition: DefinitionsFile = toml::from_str(&toml).unwrap();
let output = generate(&definition, "u8", 256).unwrap();
assert!(output.contains("CurveLut"));
assert!(output.contains("pub const LINEAR"));
assert!(output.contains("PiecewiseLinearTransfer"));
assert!(output.contains("pub const ALS_GAIN_DIV4_INTEGRATION_TIME_MS_100"));
assert!(!output.contains("f32"));
assert!(!output.contains("f64"));
}
#[test]
fn family_emits_sparse_integer_transfers_without_floats_or_curve_luts() {
let mut toml = family_header();
toml.push_str(&member_toml("div4", 100, "emit"));
toml.push_str("[gaps.white_channel]\nstatus = \"undefined\"\nreason = \"counts only\"\n");
let definition: DefinitionsFile = toml::from_str(&toml).unwrap();
assert_eq!(definition.transfer_families()["als"].members.len(), 1);
assert_eq!(definition.gaps()["white_channel"].reason, "counts only");
let output = generate(&definition, "u8", 256).unwrap();
assert!(output.contains("PiecewiseLinearTransfer"));
assert!(output.contains("pub const ALS_GAIN_DIV4_INTEGRATION_TIME_MS_100"));
assert_eq!(
output
.matches("pub const ALS_GAIN_DIV4_INTEGRATION_TIME_MS_100:")
.count(),
1
);
assert!(!output.contains("CurveLut"));
assert!(!output.contains("f32"));
assert!(!output.contains("f64"));
}
#[test]
fn running_family_budget_stops_before_a_later_invalid_member() {
let toml = r#"
[transfers]
requires = ["transfer_families_v1"]
[transfer_families.bounded]
provenance = { identity = "budget short-circuit fixture" }
input_unit = "count"
output_unit = "unit"
output_scale = 1000
max_interpolation_error = 0
max_knots = 64
max_total_knots = 4
formula = "x * x + (x * (x - 1) * (x - 2) * (x - 3) * (x - 4) * (x - 5) * (x - 6) * (x - 7) * (x - 8) * (x - 9) * (x - 10)) / (x - 20)"
selector_axes = { variant = ["a", "z"] }
[[transfer_families.bounded.members]]
selectors = { variant = "a" }
status = "emit"
applicability = { observation = [0, 10] }
[[transfer_families.bounded.members]]
selectors = { variant = "z" }
status = "emit"
applicability = { observation = [20, 21] }
"#;
let definitions = DefinitionsFile::from_toml_str(toml).unwrap();
let error = generate(&definitions, "u8", 256).unwrap_err();
assert!(error.contains("max_total_knots=4 exceeded"), "{error}");
assert!(
error.contains("11 knots across 1 emitted members"),
"{error}"
);
assert!(!error.contains("non-finite output"), "{error}");
}
#[test]
fn scaled_polynomial_family_emits_sparse_integer_transfers_without_floats() {
let toml = r#"
[transfers]
requires = ["transfer_families_v1"]
[transfer_families.als]
provenance = { identity = "test fixture" }
input_unit = "count"
output_unit = "unit"
output_scale = 1
max_interpolation_error = 1
max_knots = 8
selector_axes = { gain = ["div4"], integration_time_ms = [800] }
[transfer_families.als.model]
kind = "scaled_polynomial"
coefficients = [0.0, 0.5]
[[transfer_families.als.members]]
selectors = { gain = "div4", integration_time_ms = 800 }
status = "emit"
input_transform = { numerator = 33600, denominator = 1000000 }
applicability = { model_input = [63.0, 64.0] }
"#;
let definition: DefinitionsFile = toml::from_str(toml).unwrap();
let output = generate(&definition, "u8", 256).unwrap();
assert!(output.contains("PiecewiseLinearTransfer"));
assert!(output.contains("pub const ALS_GAIN_DIV4_INTEGRATION_TIME_MS_800"));
assert!(output.contains("1875"));
assert!(!output.contains("CurveLut"));
assert!(!output.contains("f32"));
assert!(!output.contains("f64"));
}
#[test]
fn twenty_four_member_family_inspects_all_and_emits_twelve_once() {
let toml = twenty_four_member_family_toml();
let definition: DefinitionsFile = toml::from_str(&toml).unwrap();
let family = &definition.transfer_families()["als"];
assert_eq!(family.members.len(), 24);
let emit_count = family
.members
.iter()
.filter(|member| matches!(member.status, crate::r#gen::MemberStatus::Emit))
.count();
assert_eq!(emit_count, 12);
assert_eq!(definition.gaps().len(), 2);
assert_eq!(
family.members[0].selectors["gain"],
crate::r#gen::SelectorValue::String("x1".into())
);
assert_eq!(family.members[0].input_transform, None);
assert_eq!(family.members[0].applicability.observation, Some([1, 10]));
let output = generate(&definition, "u8", 256).unwrap();
for gain in ["DIV4", "DIV8"] {
for it in [25, 50, 100, 200, 400, 800] {
let name = format!("ALS_GAIN_{gain}_INTEGRATION_TIME_MS_{it}");
assert_eq!(
output.matches(&format!("pub const {name}:")).count(),
1,
"{name} should appear once"
);
}
}
for gain in ["X1", "X2"] {
for it in [25, 50, 100, 200, 400, 800] {
let name = format!("ALS_GAIN_{gain}_INTEGRATION_TIME_MS_{it}");
assert!(
!output.contains(&format!("pub const {name}")),
"description-only {name} must not be generated"
);
}
}
assert!(!output.contains("CurveLut"));
assert!(!output.contains("f32"));
assert!(!output.contains("f64"));
}
#[test]
fn gap_collides_with_standalone_transfer() {
let toml = r#"
[transfers.white]
input_unit = "count"
output_unit = "unit"
output_scale = 1
max_interpolation_error = 1
domain = [1, 10]
formula = "x"
[gaps.white]
status = "undefined"
reason = "undefined channel"
"#;
let error =
generate(&toml::from_str::<DefinitionsFile>(toml).unwrap(), "u8", 256).unwrap_err();
assert!(error.contains("gap `white` collides with a [transfers] entry"));
}
#[test]
fn gap_collides_with_curve() {
let toml = r#"
[curves.white]
builtin = "linear"
[gaps.white]
status = "undefined"
reason = "undefined channel"
"#;
let error =
generate(&toml::from_str::<DefinitionsFile>(toml).unwrap(), "u8", 256).unwrap_err();
assert!(error.contains("gap `white` collides with a [curves] entry"));
}
#[test]
fn gap_collides_with_family_name() {
let mut toml = family_header();
toml.push_str(&member_toml("div4", 100, "emit"));
toml.push_str("[gaps.als]\nstatus = \"undefined\"\nreason = \"reserved\"\n");
let error = generate(
&toml::from_str::<DefinitionsFile>(&toml).unwrap(),
"u8",
256,
)
.unwrap_err();
assert!(error.contains("gap `als` collides with a [transfer_families] entry"));
}
#[test]
fn gap_collides_with_emitted_member() {
let mut toml = family_header();
toml.push_str(&member_toml("div4", 100, "emit"));
toml.push_str(
"[gaps.als_gain_div4_integration_time_ms_100]\n\
status = \"undefined\"\nreason = \"reserved\"\n",
);
let error = generate(
&toml::from_str::<DefinitionsFile>(&toml).unwrap(),
"u8",
256,
)
.unwrap_err();
assert!(error.contains("collides with an emitted family member"));
}
#[test]
fn blank_gap_reason_is_rejected() {
let toml = "[gaps.white]\nstatus = \"undefined\"\nreason = \" \"\n";
let error =
generate(&toml::from_str::<DefinitionsFile>(toml).unwrap(), "u8", 256).unwrap_err();
assert!(error.contains("reason must not be blank"));
}
#[test]
fn expanded_member_collides_with_standalone_transfer() {
let mut toml = family_header();
toml.push_str(
r#"
[transfers.als_gain_div4_integration_time_ms_100]
input_unit = "count"
output_unit = "unit"
output_scale = 1
max_interpolation_error = 1
domain = [1, 10]
formula = "x"
"#,
);
toml.push_str(&member_toml("div4", 100, "emit"));
let error = generate(
&toml::from_str::<DefinitionsFile>(&toml).unwrap(),
"u8",
256,
)
.unwrap_err();
assert!(error.contains("collides with a standalone [transfers] entry"));
}
#[test]
fn family_member_companion_identifier_collision_is_rejected() {
let mut toml = family_header();
toml.push_str(
r#"
[transfers.als_gain_div4_integration_time_ms_100_inputs]
input_unit = "count"
output_unit = "unit"
output_scale = 1
max_interpolation_error = 1
domain = [1, 10]
formula = "x"
"#,
);
toml.push_str(&member_toml("div4", 100, "emit"));
let error = generate(
&toml::from_str::<DefinitionsFile>(&toml).unwrap(),
"u8",
256,
)
.unwrap_err();
assert!(error.contains("duplicate Rust identifier"));
}
#[test]
fn generated_rustdoc_labels_provenance_representation_and_policy() {
let toml = r#"
[transfers]
requires = ["transfer_families_v1"]
[transfer_families.als]
provenance = { identity = "synthetic ALS application note", revision = "1.0", locator = "Table 1" }
input_unit = "count"
output_unit = "unit"
output_scale = 1
max_interpolation_error = 1
max_knots = 8
below = "error"
above = "clamp"
formula = "x"
selector_axes = { gain = ["div4"] }
[[transfer_families.als.members]]
selectors = { gain = "div4" }
status = "emit"
applicability = { observation = [1, 10] }
"#;
let out = generate(&toml::from_str::<DefinitionsFile>(toml).unwrap(), "u8", 256).unwrap();
assert!(out.contains(
r#"Source provenance: identity "synthetic ALS application note"; revision "1.0"; locator "Table 1"."#
));
assert!(out.contains(r#"Representation: "formula y = x"."#));
assert!(out.contains(
"Generation policy: requested interpolation error <= 1; max_knots = 8; below = error; above = clamp."
));
assert!(out.contains(r#"Family: "als"."#));
assert!(out.contains(r#"Selectors: "gain" = "div4"."#));
assert!(!out.contains("/// Source: "));
}
#[test]
fn generated_user_text_markdown_passes_actual_rustdoc() {
let toml = r#"
[curves."[curve_name]"]
builtin = "linear"
[transfers]
requires = ["observation_guard_v1", "source_provenance_v1"]
[transfers."[transfer_name]"]
input_unit = "[input_unit] https://example.com/units `````"
output_unit = "<kPa>"
output_scale = 1
max_interpolation_error = 1
provenance = { identity = "[missing]", url = "https://example.com/datasheet.pdf", note = "`code` ````` <tag> & http://example.org/note" }
saturation = { code = 65535, behavior = "error", provenance = { locator = "[guard]" } }
formula = "x * 0.5"
domain = [1, 10]
"#;
let out = generate(&toml::from_str::<DefinitionsFile>(toml).unwrap(), "u8", 256).unwrap();
assert!(out.contains(r#"\[curve\_name\]"#), "{out}");
assert!(out.contains(r#"\[transfer\_name\]"#), "{out}");
assert!(out.contains(r#"identity "\[missing\]""#), "{out}");
assert!(out.contains(r#"locator "\[guard\]""#), "{out}");
assert!(out.contains(r#"formula y = x \* 0.5"#), "{out}");
let provenance_url = rustdoc_debug("https://example.com/datasheet.pdf");
let input_unit = rustdoc_debug("[input_unit] https://example.com/units `````");
let output_unit = rustdoc_debug("<kPa>");
let note = rustdoc_debug("`code` ````` <tag> & http://example.org/note");
assert_eq!(provenance_url, r#"`"https://example.com/datasheet.pdf"`"#);
assert!(input_unit.starts_with("``````\""), "{input_unit}");
assert!(input_unit.ends_with("\"``````"), "{input_unit}");
assert_eq!(output_unit, r#""<kPa>""#);
for rendered in [&provenance_url, &input_unit, &output_unit, ¬e] {
assert!(out.contains(rendered), "missing {rendered:?} in:\n{out}");
}
fn documentation_block(generated: &str, first_line_marker: &str) -> String {
let mut lines = generated.lines();
while let Some(line) = lines.next() {
if !line.starts_with("///") || !line.contains(first_line_marker) {
continue;
}
let mut block = format!("{line}\n");
for line in lines.by_ref() {
if !line.starts_with("///") {
break;
}
block.push_str(line);
block.push('\n');
}
return block;
}
panic!("generated documentation block containing {first_line_marker:?}");
}
let curve_docs = documentation_block(&out, "monotonic curve.");
let transfer_docs = documentation_block(&out, "sparse physical transfer function.");
let rustdoc_source = format!(
"{curve_docs}pub struct CurveProbe;\n\n\
{transfer_docs}pub struct TransferProbe;\n"
);
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory = std::env::temp_dir().join(format!(
"ph_curves_generated_rustdoc_{}_{}",
std::process::id(),
unique
));
std::fs::create_dir_all(&directory).unwrap();
let source = directory.join("probe.rs");
std::fs::write(&source, rustdoc_source).unwrap();
let output = std::process::Command::new("rustdoc")
.arg("--edition=2024")
.arg("--crate-type=lib")
.arg("--crate-name=provenance_probe")
.arg("-Dwarnings")
.arg("--out-dir")
.arg(&directory)
.arg(&source)
.output()
.unwrap();
let _ = std::fs::remove_dir_all(&directory);
assert!(
output.status.success(),
"rustdoc failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn observation_guard_rustdoc_is_policy_unless_cited() {
let policy_only = r#"
[transfers]
requires = ["transfer_families_v1"]
[transfer_families.als]
provenance = { identity = "datasheet" }
input_unit = "count"
output_unit = "unit"
output_scale = 1
max_interpolation_error = 1
max_knots = 8
saturation = { code = 65535, behavior = "clamp" }
formula = "x"
selector_axes = { variant = ["clamp"] }
[[transfer_families.als.members]]
selectors = { variant = "clamp" }
status = "emit"
applicability = { observation = [1, 10] }
"#;
let cited = r#"
[transfers]
requires = ["transfer_families_v1"]
[transfer_families.als]
provenance = { identity = "datasheet" }
input_unit = "count"
output_unit = "unit"
output_scale = 1
max_interpolation_error = 1
max_knots = 8
saturation = { code = 65535, behavior = "clamp", provenance = { locator = "§5.2 overflow" } }
formula = "x"
selector_axes = { variant = ["clamp"] }
[[transfer_families.als.members]]
selectors = { variant = "clamp" }
status = "emit"
applicability = { observation = [1, 10] }
"#;
let policy_out = generate(
&toml::from_str::<DefinitionsFile>(policy_only).unwrap(),
"u8",
256,
)
.unwrap();
assert!(policy_out.contains(
"Classification of this code as saturation is declared consumer/device policy, not inferred from the integer value."
));
assert!(!policy_out.contains("cited from"));
let cited_out = generate(
&toml::from_str::<DefinitionsFile>(cited).unwrap(),
"u8",
256,
)
.unwrap();
assert!(cited_out.contains(
r#"Classification of this code as saturation is cited from identity "datasheet"; locator "§5.2 overflow" and applied as declared policy"#
));
}
}