Skip to main content

oxinum_core/
lib.rs

1#![forbid(unsafe_code)]
2//! Core traits, error types, and rounding modes for the OxiNum ecosystem.
3//!
4//! This crate provides the foundational types shared by `oxinum-int`,
5//! `oxinum-float`, and `oxinum-rational`.
6
7/// Re-export the `Sign` type from `dashu-base`.
8///
9/// `Sign::Positive` / `Sign::Negative` indicate the sign of a number.
10pub use dashu_base::Sign;
11
12// Re-export useful dashu-base traits so downstream crates get them from one place.
13pub use dashu_base::{
14    Abs, AbsOrd, BitTest, CubicRoot, DivEuclid, DivRem, DivRemAssign, DivRemEuclid, EstimatedLog2,
15    ExtendedGcd, Gcd, Inverse, PowerOfTwo, RemEuclid, Signed, SquareRoot, UnsignedAbs,
16};
17
18// ---------------------------------------------------------------------------
19// Error types
20// ---------------------------------------------------------------------------
21
22/// Convenience alias for `Result<T, OxiNumError>`.
23pub type OxiNumResult<T> = Result<T, OxiNumError>;
24
25/// Errors from OxiNum operations.
26#[non_exhaustive]
27#[derive(Debug, Clone, PartialEq, Eq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub enum OxiNumError {
30    /// Failed to parse a number from a string.
31    Parse(std::borrow::Cow<'static, str>),
32    /// Precision constraint violated.
33    Precision(std::borrow::Cow<'static, str>),
34    /// Division by zero.
35    DivByZero,
36    /// Arithmetic overflow (e.g. result exceeds a primitive range).
37    Overflow(std::borrow::Cow<'static, str>),
38    /// Invalid radix for base conversion (must be 2..=36).
39    InvalidRadix(u32),
40    /// Input is outside the domain of the function (e.g. sqrt of a negative).
41    Domain(std::borrow::Cow<'static, str>),
42}
43
44impl OxiNumError {
45    /// Returns a new error of the same variant with `ctx` prepended to the
46    /// message, for message-bearing variants.
47    ///
48    /// For variants without a free-form message (`DivByZero`, `InvalidRadix`)
49    /// the original value is returned unchanged because their `Display`
50    /// already conveys the kind precisely.
51    ///
52    /// # Examples
53    ///
54    /// ```
55    /// use oxinum_core::OxiNumError;
56    ///
57    /// let e = OxiNumError::Parse("bad digit".into()).context("while reading row 4");
58    /// assert!(e.to_string().contains("while reading row 4:"));
59    /// assert!(e.to_string().contains("bad digit"));
60    ///
61    /// // Variants without a message are returned untouched.
62    /// assert_eq!(
63    ///     OxiNumError::DivByZero.context("noop"),
64    ///     OxiNumError::DivByZero,
65    /// );
66    /// ```
67    #[must_use]
68    pub fn context(self, ctx: impl AsRef<str>) -> Self {
69        let ctx = ctx.as_ref();
70        match self {
71            Self::Parse(s) => Self::Parse(format!("{ctx}: {s}").into()),
72            Self::Precision(s) => Self::Precision(format!("{ctx}: {s}").into()),
73            Self::Overflow(s) => Self::Overflow(format!("{ctx}: {s}").into()),
74            Self::Domain(s) => Self::Domain(format!("{ctx}: {s}").into()),
75            other => other,
76        }
77    }
78}
79
80impl std::fmt::Display for OxiNumError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            Self::Parse(s) => write!(f, "parse error: {s}"),
84            Self::Precision(s) => write!(f, "precision error: {s}"),
85            Self::DivByZero => write!(f, "division by zero"),
86            Self::Overflow(s) => write!(f, "overflow: {s}"),
87            Self::InvalidRadix(r) => write!(f, "invalid radix: {r} (must be 2..=36)"),
88            Self::Domain(s) => write!(f, "domain error: {s}"),
89        }
90    }
91}
92
93impl std::error::Error for OxiNumError {}
94
95impl From<OxiNumError> for std::io::Error {
96    fn from(e: OxiNumError) -> Self {
97        std::io::Error::other(e.to_string())
98    }
99}
100
101// ---------------------------------------------------------------------------
102// ParseNumberError -- positional parse diagnostics
103// ---------------------------------------------------------------------------
104
105/// Rich parse-error diagnostic carrying the offending message together with
106/// the 1-based line and column where the parser stopped.
107///
108/// This is a standalone error type (not a variant of [`OxiNumError`]) so that
109/// the existing `OxiNumError` size envelope is preserved. Convert via
110/// `OxiNumError::from(parse_err)` (or `?`) to fold a positional diagnostic
111/// back into the unified error type — the resulting `OxiNumError::Parse`
112/// message will include the line and column.
113///
114/// # Examples
115///
116/// ```
117/// use oxinum_core::{OxiNumError, ParseNumberError};
118///
119/// let pe = ParseNumberError::new("unexpected character", 2, 5);
120/// assert!(pe.to_string().contains("line 2"));
121/// assert!(pe.to_string().contains("column 5"));
122///
123/// // Fold into the unified error type.
124/// let e: OxiNumError = pe.into();
125/// assert!(matches!(e, OxiNumError::Parse(_)));
126/// assert!(e.to_string().contains("line 2"));
127/// assert!(e.to_string().contains("col 5"));
128/// ```
129#[derive(Debug, Clone, PartialEq, Eq)]
130#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
131pub struct ParseNumberError {
132    /// Human-readable description of why the parse failed.
133    pub message: String,
134    /// 1-based line where the parser stopped.
135    pub line: usize,
136    /// 1-based column where the parser stopped.
137    pub column: usize,
138}
139
140impl ParseNumberError {
141    /// Construct a new [`ParseNumberError`] from a message and a 1-based
142    /// `(line, column)` position.
143    pub fn new(message: impl Into<String>, line: usize, column: usize) -> Self {
144        Self {
145            message: message.into(),
146            line,
147            column,
148        }
149    }
150}
151
152impl std::fmt::Display for ParseNumberError {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        write!(
155            f,
156            "parse error at line {line}, column {column}: {message}",
157            line = self.line,
158            column = self.column,
159            message = self.message,
160        )
161    }
162}
163
164impl std::error::Error for ParseNumberError {}
165
166impl From<ParseNumberError> for OxiNumError {
167    fn from(e: ParseNumberError) -> Self {
168        let ParseNumberError {
169            message,
170            line,
171            column,
172        } = e;
173        OxiNumError::Parse(format!("{message} (line {line}, col {column})").into())
174    }
175}
176
177// ---------------------------------------------------------------------------
178// Rounding mode (dashu-independent)
179// ---------------------------------------------------------------------------
180
181/// Rounding modes for arbitrary-precision arithmetic.
182///
183/// This enum is independent of any backend library and provides a common
184/// vocabulary for precision control across all OxiNum numeric types.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
186#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
187pub enum RoundingMode {
188    /// Round toward positive infinity.
189    Up,
190    /// Round toward negative infinity.
191    Down,
192    /// Round toward positive infinity (alias for Up in unsigned context).
193    Ceiling,
194    /// Round toward negative infinity (alias for Down in unsigned context).
195    Floor,
196    /// Round half toward positive infinity.
197    HalfUp,
198    /// Round half toward negative infinity.
199    HalfDown,
200    /// Round half to the nearest even digit (banker's rounding).
201    HalfEven,
202    /// Exact result required -- error if rounding would occur.
203    Unnecessary,
204}
205
206impl std::fmt::Display for RoundingMode {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        let name = match self {
209            Self::Up => "Up",
210            Self::Down => "Down",
211            Self::Ceiling => "Ceiling",
212            Self::Floor => "Floor",
213            Self::HalfUp => "HalfUp",
214            Self::HalfDown => "HalfDown",
215            Self::HalfEven => "HalfEven",
216            Self::Unnecessary => "Unnecessary",
217        };
218        f.write_str(name)
219    }
220}
221
222// ---------------------------------------------------------------------------
223// Numeric trait hierarchy
224// ---------------------------------------------------------------------------
225
226/// Marker trait for all OxiNum numeric types.
227///
228/// Implementors must support display, debug, cloning, and basic equality.
229pub trait OxiNum: std::fmt::Display + std::fmt::Debug + Clone + PartialEq {
230    /// Returns `true` if this value is zero.
231    fn is_zero(&self) -> bool;
232
233    /// Returns `true` if this value is one.
234    fn is_one(&self) -> bool;
235}
236
237/// Trait for numeric types that carry a sign.
238pub trait OxiSigned: OxiNum {
239    /// Returns the sign of this number.
240    fn signum(&self) -> Sign;
241
242    /// Returns the absolute value.
243    fn abs(&self) -> Self;
244
245    /// Returns `true` if this value is negative.
246    fn is_negative(&self) -> bool {
247        self.signum() == Sign::Negative
248    }
249
250    /// Returns `true` if this value is positive (and not zero).
251    fn is_positive(&self) -> bool {
252        !self.is_zero() && self.signum() == Sign::Positive
253    }
254}
255
256/// Trait for unsigned numeric types.
257pub trait OxiUnsigned: OxiNum {}
258
259// ---------------------------------------------------------------------------
260// Conversion traits
261// ---------------------------------------------------------------------------
262
263/// Parse a number from an arbitrary-radix string.
264pub trait FromRadix: Sized {
265    /// Parse `src` in the given `radix` (2..=36).
266    ///
267    /// # Errors
268    ///
269    /// Returns `OxiNumError::Parse` on invalid digits or
270    /// `OxiNumError::InvalidRadix` if radix is out of range.
271    fn from_radix(src: &str, radix: u32) -> OxiNumResult<Self>;
272}
273
274/// Format a number as a string in an arbitrary radix.
275pub trait ToRadix {
276    /// Returns the string representation in the given `radix` (2..=36).
277    ///
278    /// # Errors
279    ///
280    /// Returns `OxiNumError::InvalidRadix` if radix is out of range.
281    fn to_radix(&self, radix: u32) -> OxiNumResult<String>;
282}
283
284// ---------------------------------------------------------------------------
285// Power / roots traits
286// ---------------------------------------------------------------------------
287
288/// Exponentiation trait.
289pub trait Pow<Exp> {
290    /// The output type.
291    type Output;
292
293    /// Raises `self` to the power `exp`.
294    fn pow(&self, exp: Exp) -> Self::Output;
295}
296
297/// Root extraction trait.
298pub trait Roots {
299    /// Integer square root (floor).
300    fn sqrt(&self) -> Self;
301
302    /// Integer cube root (floor).
303    fn cbrt(&self) -> Self;
304
305    /// Integer nth root (floor).
306    fn nth_root(&self, n: u32) -> Self;
307}
308
309// ---------------------------------------------------------------------------
310// Modular arithmetic trait
311// ---------------------------------------------------------------------------
312
313/// Modular arithmetic operations.
314pub trait ModularArithmetic {
315    /// Computes `(self + rhs) mod modulus`.
316    fn mod_add(&self, rhs: &Self, modulus: &Self) -> Self;
317
318    /// Computes `(self - rhs) mod modulus`.
319    fn mod_sub(&self, rhs: &Self, modulus: &Self) -> Self;
320
321    /// Computes `(self * rhs) mod modulus`.
322    fn mod_mul(&self, rhs: &Self, modulus: &Self) -> Self;
323
324    /// Computes `self^exp mod modulus` via binary exponentiation.
325    fn mod_pow(&self, exp: &Self, modulus: &Self) -> Self;
326}
327
328// ---------------------------------------------------------------------------
329// Primality trait
330// ---------------------------------------------------------------------------
331
332/// Primality testing operations.
333pub trait Primality {
334    /// Returns `true` if the number is (probably) prime.
335    ///
336    /// Uses Miller-Rabin with the given number of witnesses.
337    /// With `witnesses = 0`, uses a deterministic set for small values
338    /// and a sensible default count for large values.
339    fn is_probably_prime(&self, witnesses: u32) -> bool;
340
341    /// Returns the next prime greater than `self`.
342    fn next_prime(&self) -> Self;
343}
344
345// ---------------------------------------------------------------------------
346// Tests
347// ---------------------------------------------------------------------------
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn error_display_parse() {
355        let e = OxiNumError::Parse("bad input".into());
356        assert!(e.to_string().contains("bad input"));
357    }
358
359    #[test]
360    fn error_display_precision() {
361        let e = OxiNumError::Precision("too low".into());
362        assert!(e.to_string().contains("too low"));
363    }
364
365    #[test]
366    fn error_display_div_by_zero() {
367        let e = OxiNumError::DivByZero;
368        assert_eq!(e.to_string(), "division by zero");
369    }
370
371    #[test]
372    fn error_display_overflow() {
373        let e = OxiNumError::Overflow("u64 max exceeded".into());
374        assert!(e.to_string().contains("u64 max exceeded"));
375    }
376
377    #[test]
378    fn error_display_invalid_radix() {
379        let e = OxiNumError::InvalidRadix(42);
380        assert!(e.to_string().contains("42"));
381        assert!(e.to_string().contains("must be 2..=36"));
382    }
383
384    #[test]
385    fn error_display_domain() {
386        let e = OxiNumError::Domain("sqrt of negative is undefined for real BigFloat".into());
387        assert_eq!(
388            e.to_string(),
389            "domain error: sqrt of negative is undefined for real BigFloat"
390        );
391    }
392
393    #[test]
394    fn context_prefixes_domain_message() {
395        let e = OxiNumError::Domain("sqrt of negative".into()).context("BigFloat::sqrt");
396        match &e {
397            OxiNumError::Domain(s) => assert_eq!(s, "BigFloat::sqrt: sqrt of negative"),
398            other => panic!("expected Domain, got {other:?}"),
399        }
400        assert!(e.to_string().contains("domain error:"));
401        assert!(e.to_string().contains("BigFloat::sqrt:"));
402    }
403
404    #[test]
405    fn error_into_io_error() {
406        let e = OxiNumError::DivByZero;
407        let io_err: std::io::Error = e.into();
408        assert_eq!(io_err.kind(), std::io::ErrorKind::Other);
409        assert!(io_err.to_string().contains("division by zero"));
410    }
411
412    #[test]
413    fn sign_positive() {
414        let s = Sign::Positive;
415        assert_eq!(s, Sign::Positive);
416    }
417
418    #[test]
419    fn rounding_mode_display() {
420        assert_eq!(RoundingMode::HalfEven.to_string(), "HalfEven");
421        assert_eq!(RoundingMode::Up.to_string(), "Up");
422        assert_eq!(RoundingMode::Unnecessary.to_string(), "Unnecessary");
423    }
424
425    #[test]
426    fn rounding_mode_equality() {
427        assert_eq!(RoundingMode::Floor, RoundingMode::Floor);
428        assert_ne!(RoundingMode::Up, RoundingMode::Down);
429    }
430
431    #[test]
432    fn error_is_send_sync() {
433        fn assert_send_sync<T: Send + Sync>() {}
434        assert_send_sync::<OxiNumError>();
435    }
436
437    #[test]
438    fn error_size_is_small() {
439        // OxiNumError should be reasonably small (3 words or fewer on the stack)
440        let size = std::mem::size_of::<OxiNumError>();
441        assert!(size <= 32, "OxiNumError is {size} bytes, expected <= 32");
442    }
443
444    #[test]
445    fn oxinumerror_implements_std_error() {
446        fn assert_error<E: std::error::Error>(_: &E) {}
447        let e = OxiNumError::DivByZero;
448        assert_error(&e);
449        // Also verify it can be boxed as a trait object.
450        let _boxed: Box<dyn std::error::Error> = Box::new(OxiNumError::DivByZero);
451    }
452
453    #[test]
454    fn error_is_std_error() {
455        let e: Box<dyn std::error::Error> = Box::new(OxiNumError::Parse("test".into()));
456        assert!(e.to_string().contains("test"));
457    }
458
459    #[test]
460    fn oxi_num_result_alias() {
461        let ok: OxiNumResult<u32> = Ok(42);
462        assert_eq!(ok, Ok(42));
463        let err: OxiNumResult<u32> = Err(OxiNumError::DivByZero);
464        assert!(err.is_err());
465    }
466
467    // -----------------------------------------------------------------------
468    // ParseNumberError + context() (Item 2 — diagnostics enrichment)
469    // -----------------------------------------------------------------------
470
471    #[test]
472    fn parse_number_error_constructs_and_displays() {
473        let pe = ParseNumberError::new("bad digit", 3, 7);
474        assert_eq!(pe.message, "bad digit");
475        assert_eq!(pe.line, 3);
476        assert_eq!(pe.column, 7);
477
478        // Native Display uses the full words "line" / "column".
479        let pe_disp = pe.to_string();
480        assert!(pe_disp.contains("line 3"), "got {pe_disp}");
481        assert!(pe_disp.contains("column 7"), "got {pe_disp}");
482        assert!(pe_disp.contains("bad digit"), "got {pe_disp}");
483
484        // Folding into OxiNumError yields Parse(...) and embeds the position.
485        let oe: OxiNumError = pe.into();
486        match &oe {
487            OxiNumError::Parse(_) => {}
488            other => panic!("expected Parse, got {other:?}"),
489        }
490        let oe_disp = oe.to_string();
491        assert!(oe_disp.contains("bad digit"), "got {oe_disp}");
492        // Folded form uses "line {line}, col {column}".
493        assert!(oe_disp.contains("line 3"), "got {oe_disp}");
494        assert!(oe_disp.contains("col 7"), "got {oe_disp}");
495    }
496
497    #[test]
498    fn parse_number_error_is_std_error() {
499        let boxed: Box<dyn std::error::Error> = Box::new(ParseNumberError::new("oops", 1, 1));
500        assert!(boxed.to_string().contains("oops"));
501    }
502
503    #[test]
504    fn context_prefixes_message_variants() {
505        let parse = OxiNumError::Parse("x".into()).context("at A");
506        match parse {
507            OxiNumError::Parse(ref s) => assert_eq!(s, "at A: x"),
508            other => panic!("expected Parse, got {other:?}"),
509        }
510        assert!(parse.to_string().contains("at A:"));
511
512        let precision = OxiNumError::Precision("y".into()).context("at B");
513        match precision {
514            OxiNumError::Precision(ref s) => assert_eq!(s, "at B: y"),
515            other => panic!("expected Precision, got {other:?}"),
516        }
517
518        let overflow = OxiNumError::Overflow("z".into()).context("at C");
519        match overflow {
520            OxiNumError::Overflow(ref s) => assert_eq!(s, "at C: z"),
521            other => panic!("expected Overflow, got {other:?}"),
522        }
523    }
524
525    #[test]
526    fn context_leaves_kindful_variants_unchanged() {
527        assert_eq!(
528            OxiNumError::DivByZero.context("ignored"),
529            OxiNumError::DivByZero,
530        );
531        assert_eq!(
532            OxiNumError::InvalidRadix(37).context("ignored"),
533            OxiNumError::InvalidRadix(37),
534        );
535    }
536
537    #[test]
538    fn existing_size_of_oxinumerror_unchanged() {
539        // Re-assert (alongside `error_size_is_small`) that Item 2's additions
540        // did not bloat the variant payload.
541        let size = std::mem::size_of::<OxiNumError>();
542        assert!(size <= 32, "OxiNumError is {size} bytes, expected <= 32");
543    }
544}
545
546#[cfg(test)]
547mod proptests {
548    use super::*;
549    use proptest::prelude::*;
550
551    proptest! {
552        #[test]
553        fn parse_display_roundtrip(s in any::<String>()) {
554            let e = OxiNumError::Parse(s.clone().into());
555            prop_assert!(e.to_string().contains(&s));
556        }
557
558        #[test]
559        fn precision_display_roundtrip(s in any::<String>()) {
560            let e = OxiNumError::Precision(s.clone().into());
561            prop_assert!(e.to_string().contains(&s));
562        }
563
564        #[test]
565        fn overflow_display_roundtrip(s in any::<String>()) {
566            let e = OxiNumError::Overflow(s.clone().into());
567            prop_assert!(e.to_string().contains(&s));
568        }
569
570        #[test]
571        fn domain_display_roundtrip(s in any::<String>()) {
572            let e = OxiNumError::Domain(s.clone().into());
573            prop_assert!(e.to_string().contains(&s));
574        }
575
576        #[test]
577        fn parse_number_error_display_roundtrip(
578            msg in any::<String>(),
579            line in 1usize..=10_000,
580            column in 1usize..=10_000,
581        ) {
582            let pe = ParseNumberError::new(msg.clone(), line, column);
583            let disp = pe.to_string();
584            prop_assert!(disp.contains(&msg));
585            prop_assert!(disp.contains(&line.to_string()));
586            prop_assert!(disp.contains(&column.to_string()));
587        }
588    }
589}
590
591#[cfg(all(test, feature = "serde"))]
592mod serde_tests {
593    use super::*;
594
595    fn roundtrip_oxi(original: OxiNumError) {
596        let json = serde_json::to_string(&original).expect("serialize OxiNumError");
597        let back: OxiNumError = serde_json::from_str(&json).expect("deserialize OxiNumError");
598        assert_eq!(back, original, "round-trip mismatch for {original:?}");
599    }
600
601    fn roundtrip_rounding(original: RoundingMode) {
602        let json = serde_json::to_string(&original).expect("serialize RoundingMode");
603        let back: RoundingMode = serde_json::from_str(&json).expect("deserialize RoundingMode");
604        assert_eq!(back, original, "round-trip mismatch for {original:?}");
605    }
606
607    #[test]
608    fn oxinum_error_json_roundtrip_all_variants() {
609        roundtrip_oxi(OxiNumError::Parse("e".into()));
610        roundtrip_oxi(OxiNumError::Precision("p".into()));
611        roundtrip_oxi(OxiNumError::DivByZero);
612        roundtrip_oxi(OxiNumError::Overflow("o".into()));
613        roundtrip_oxi(OxiNumError::InvalidRadix(3));
614        roundtrip_oxi(OxiNumError::Domain("sqrt of negative".into()));
615    }
616
617    #[test]
618    fn rounding_mode_json_roundtrip_all_variants() {
619        roundtrip_rounding(RoundingMode::Up);
620        roundtrip_rounding(RoundingMode::Down);
621        roundtrip_rounding(RoundingMode::Ceiling);
622        roundtrip_rounding(RoundingMode::Floor);
623        roundtrip_rounding(RoundingMode::HalfUp);
624        roundtrip_rounding(RoundingMode::HalfDown);
625        roundtrip_rounding(RoundingMode::HalfEven);
626        roundtrip_rounding(RoundingMode::Unnecessary);
627    }
628
629    #[test]
630    fn parse_number_error_json_roundtrip() {
631        let pe = ParseNumberError::new("bad", 4, 9);
632        let json = serde_json::to_string(&pe).expect("serialize ParseNumberError");
633        let back: ParseNumberError =
634            serde_json::from_str(&json).expect("deserialize ParseNumberError");
635        assert_eq!(back, pe);
636    }
637}