use crate::char_struct::CharType;
use crate::number;
use crate::rules::RuleMeta;
use crate::rules::context::RuleContext;
use crate::rules::traits::{BrailleRule, Phase, RuleResult};
pub static META_40: RuleMeta = RuleMeta {
section: "40",
subsection: None,
name: "number_prefix",
standard_ref: "2024 Korean Braille Standard, Ch.5 Sec.11 Art.40",
description: "Number indicator ⠼ (60) before first digit in number sequence",
};
pub const NUMBER_INDICATOR: u8 = 60;
#[cfg(test)]
fn encode_digit(ch: char) -> Result<u8, String> {
number::encode_number(ch)
}
pub struct Rule40;
impl BrailleRule for Rule40 {
fn meta(&self) -> &'static RuleMeta {
&META_40
}
fn phase(&self) -> Phase {
Phase::CoreEncoding
}
fn matches(&self, ctx: &RuleContext) -> bool {
matches!(ctx.char_type, CharType::Number(_))
}
fn apply(&self, ctx: &mut RuleContext) -> Result<RuleResult, String> {
let CharType::Number(c) = ctx.char_type else {
return Ok(RuleResult::Skip);
};
if !ctx.state.is_number {
let needs_prefix =
!is_number_continuation(ctx.word_chars, ctx.index, ctx.state.english_indicator);
if needs_prefix {
ctx.emit(NUMBER_INDICATOR);
if ctx
.prev_char()
.is_some_and(|prev| prev == '\'' || prev == '\u{2019}')
{
ctx.emit(4);
}
if ctx
.index
.checked_sub(1)
.is_some_and(|point| is_leading_decimal_point(ctx.word_chars, point))
{
ctx.emit(crate::unicode::decode_unicode('⠲'));
}
}
ctx.state.is_number = true;
}
let digit = number::encode_number(*c)?;
ctx.emit(digit);
Ok(RuleResult::Consumed)
}
}
pub fn is_leading_decimal_point(word_chars: &[char], index: usize) -> bool {
word_chars.get(index) == Some(&'.')
&& word_chars.get(index + 1).is_some_and(char::is_ascii_digit)
&& !index
.checked_sub(1)
.and_then(|before| word_chars.get(before))
.is_some_and(|prev| prev.is_alphanumeric() || *prev == '.')
}
pub fn is_number_continuation(word_chars: &[char], index: usize, in_korean_document: bool) -> bool {
if index == 0 || !matches!(word_chars[index - 1], '.' | ',') {
return false;
}
if in_korean_document {
return index >= 2 && word_chars[index - 2].is_numeric();
}
word_chars[..index]
.iter()
.rev()
.find(|ch| !matches!(ch, '.' | ','))
.is_some_and(|ch| ch.is_numeric())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::unicode::decode_unicode;
#[rstest::rstest]
#[case::one('1', '⠁')]
#[case::zero('0', '⠚')]
#[case::nine('9', '⠊')]
fn encodes_digits(#[case] ch: char, #[case] expected: char) {
assert_eq!(encode_digit(ch).unwrap(), decode_unicode(expected));
}
#[test]
fn invalid_digit() {
assert!(encode_digit('a').is_err());
}
#[rstest::rstest]
#[case::korean_decimal("3.9", 2, true, true)]
#[case::korean_grouped("1,000", 2, true, true)]
#[case::korean_repeated_period("4..7", 3, true, false)]
#[case::ueb_repeated_period("4..7", 3, false, true)]
#[case::roman_period("M.2", 2, false, false)]
#[case::roman_period_in_korean("M.2", 2, true, false)]
#[case::roman_comma("X,1", 2, true, false)]
#[case::korean_comma("2만,4142", 3, true, false)]
#[case::leading_period(".47", 1, false, false)]
#[case::hyphen("3-4", 2, false, false)]
#[case::first_digit("7", 0, false, false)]
fn continuation_chars(
#[case] input: &str,
#[case] index: usize,
#[case] in_korean_document: bool,
#[case] expected: bool,
) {
assert_eq!(
is_number_continuation(
&input.chars().collect::<Vec<_>>(),
index,
in_korean_document,
),
expected
);
}
#[rstest::rstest]
#[case::capital_identifier("가 M.2 나", "⠍⠲⠼⠃")]
#[case::all_caps_identifier("가 NO.1 나", "⠕⠲⠼⠁")]
#[case::korean_before_comma("가 2만,4142명 나", "⠑⠒⠐⠼⠙")]
#[case::ueb_multiple_periods("4..7", "⠼⠙⠲⠲⠛")]
fn non_numeric_left_side_does_not_suppress_number_indicator(
#[case] input: &str,
#[case] expected_fragment: &str,
) {
let actual = crate::encode_to_unicode(input).expect("input must encode");
assert!(
actual.contains(expected_fragment),
"missing rule-40 number indicator in {actual}"
);
}
#[test]
fn number_with_ascii_unit_prefix_handled_by_rule69() {
let cases = vec!["1kg", "5cm", "10mm", "3m", "2h", "100GB"];
for input in cases {
let result = crate::encode(input);
assert!(
result.is_ok(),
"encode({input}) should succeed via Rule69 path"
);
let bytes = result.unwrap();
assert!(!bytes.is_empty(), "non-empty output for {input}");
}
}
#[test]
fn rule40_apply_skip_for_non_number_ctx() {
let mut owned = crate::test_helpers::CtxOwned::for_text("가", false);
let mut ctx = owned.ctx_at(0);
let outcome = Rule40.apply(&mut ctx).unwrap();
assert!(matches!(outcome, RuleResult::Skip));
}
}
#[cfg(test)]
mod number_prefix_coverage {
#[rstest::rstest]
#[case::straight_quote("그는 '2026 년")]
#[case::typographic_quote("그는 \u{2019}2026 년")]
#[case::plain_number("그는 2026 년")]
fn a_number_after_a_quote_encodes(#[case] input: &str) {
assert!(crate::encode_to_unicode(input).is_ok());
}
}