Skip to main content

lat_long/
fmt.rs

1//! Formatting primitives for geographic angles.
2//!
3//! This module exposes the [`Formatter`] trait, the [`FormatOptions`] builder,
4//! and the [`FormatKind`] enum that together drive how [`crate::Latitude`],
5//! [`crate::Longitude`], and [`crate::Coordinate`] values are rendered.
6//!
7//! Four output styles are supported (see [`FormatKind`] for examples):
8//!
9//! * [`FormatKind::Decimal`] — plain decimal degrees, e.g. `48.858222`.
10//! * [`FormatKind::DmsSigned`] — DMS with a leading sign, e.g. `48° 51′ 29.6″`.
11//! * [`FormatKind::DmsLabeled`] — DMS with a cardinal label, e.g. `48° 51′ 29.6″ N`.
12//! * [`FormatKind::DmsBare`] — fixed-width DMS without symbols, e.g. `+048:51:29.600000`.
13//!
14//! The [`Display`](core::fmt::Display) implementations on the public types
15//! delegate to [`Formatter::format`] with sensible defaults. Use
16//! [`Formatter::to_formatted_string`] when you need an owned `String` and want
17//! to pick a non-default style or precision.
18//!
19//! # Examples
20//!
21//! ```rust
22//! use lat_long::{Angle, Latitude, fmt::{FormatOptions, Formatter}};
23//!
24//! let lat = Latitude::new(48, 51, 29.6).unwrap();
25//! let dms = lat.to_formatted_string(&FormatOptions::dms_labeled().with_latitude_labels());
26//! assert!(dms.ends_with(" N"));
27//! ```
28//!
29
30use crate::inner;
31use core::{
32    fmt::{Debug, Write},
33    hash::Hash,
34};
35use ordered_float::OrderedFloat;
36
37// ---------------------------------------------------------------------------
38// Public Types
39// ---------------------------------------------------------------------------
40
41///
42/// Common interface for writing a value to a [`Write`] target using a
43/// [`FormatOptions`] descriptor.
44///
45/// Implementations exist for [`OrderedFloat<f64>`] (the underlying numeric
46/// representation of an angle) and for each public coordinate type. Most
47/// callers will reach for [`Formatter::to_formatted_string`] when they want
48/// an owned `String`.
49///
50/// # Examples
51///
52/// ```rust
53/// use lat_long::{Angle, Longitude, fmt::{FormatOptions, Formatter}};
54///
55/// let lon = Longitude::new(-122, 19, 59.0).unwrap();
56/// let bare = lon.to_formatted_string(&FormatOptions::dms_bare());
57/// assert!(bare.starts_with('-'));
58/// ```
59///
60pub trait Formatter {
61    ///
62    ///  Write `self` to `f` according to `options`.
63    ///
64    /// Implementations should respect every relevant field of `options`
65    /// (kind, precision, labels) and produce no extraneous whitespace.
66    ///
67    fn format<W: Write>(&self, f: &mut W, options: &FormatOptions) -> std::fmt::Result;
68
69    /// Convenience helper: render `self` into a new `String`.
70    ///
71    /// This is implemented in terms of [`Formatter::format`] and a `String`
72    /// buffer, so it cannot fail in practice.
73    fn to_formatted_string(&self, fmt: &FormatOptions) -> String {
74        let mut buffer = String::new();
75        self.format(&mut buffer, fmt).unwrap();
76        buffer
77    }
78}
79
80///
81/// The default format is [`FormatKind::Decimal`] (plain decimal degrees).
82/// When you use the alternate flag (`{:#}`) the default DMS variant is used.
83///
84#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
85pub struct FormatOptions {
86    precision: Option<usize>,
87    kind: FormatKind,
88    labels: Option<(char, char)>,
89}
90
91///
92/// | Variant      | Example              |
93/// |--------------|----------------------|
94/// | `Decimal`    | `48.8582`            |
95/// | `DmsSigned`  | `48° 51′ 29.6″`      |
96/// | `DmsLabeled` | `48° 51′ 29.6″ N`    |
97/// | `DmsBare`    | `+048:51:29.600000`  |
98///
99#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
100pub enum FormatKind {
101    #[default]
102    ///
103    /// Format as decimal degrees, e.g. `48.8582`. This format has a *default* precision
104    /// of 8 decimal places.
105    ///
106    Decimal,
107
108    /// Format as degrees with Unicode symbols, e.g. `-48° 51′ 29.600000″`. This format
109    /// has a *default* precision of 6 decimal places.
110    DmsSigned,
111
112    /// Format as degrees with a cardinal-direction label, e.g. `48° 51′ 29.600000″ N`.
113    /// This format has a *default* precision of 6 decimal places.
114    DmsLabeled,
115
116    /// Format as degrees with no symbols, e.g. `048:51:29.600000`. This format has a
117    /// *minimum* precision of 4 decimal places, and a *default* precision of 6.
118    DmsBare,
119}
120
121// ---------------------------------------------------------------------------
122// Public Constants
123// ---------------------------------------------------------------------------
124
125///
126/// Default number of fractional digits used when rendering decimal degrees.
127///
128pub const DEFAULT_DECIMAL_PRECISION: usize = 8;
129
130///
131/// Default number of fractional digits used for the seconds component of any
132/// DMS rendering.
133///
134pub const DEFAULT_DMS_PRECISION: usize = 6;
135
136///
137/// Minimum number of fractional digits enforced by [`FormatKind::DmsBare`].
138///
139/// The bare format is designed to be machine-parseable, so its seconds field
140/// has a guaranteed-minimum width regardless of the requested precision.
141///
142pub const MINIMUM_DMS_BARE_PRECISION: usize = 4;
143
144// ---------------------------------------------------------------------------
145// Implementations >> Formatter
146// ---------------------------------------------------------------------------
147
148impl Formatter for OrderedFloat<f64> {
149    fn format<W: Write>(&self, f: &mut W, options: &FormatOptions) -> std::fmt::Result {
150        formatter_impl(*self, f, options)
151    }
152}
153
154// ---------------------------------------------------------------------------
155// Implementations >> FormatOptions
156// ---------------------------------------------------------------------------
157
158impl From<FormatKind> for FormatOptions {
159    fn from(kind: FormatKind) -> Self {
160        Self::new(kind)
161    }
162}
163
164impl FormatOptions {
165    const fn new(kind: FormatKind) -> Self {
166        Self {
167            precision: None,
168            kind,
169            labels: None,
170        }
171    }
172
173    ///
174    /// Return a [`FormatOptions`] for decimal degrees with the default precision.
175    ///
176    pub const fn decimal() -> Self {
177        Self::new(FormatKind::Decimal).with_default_precision()
178    }
179
180    ///
181    /// Return a [`FormatOptions`] for degrees, minutes, seconds with the default precision.
182    ///
183    pub const fn dms() -> Self {
184        Self::dms_signed()
185    }
186
187    ///
188    /// Return a [`FormatOptions`] for signed degrees, minutes, seconds with the default precision.
189    ///
190    pub const fn dms_signed() -> Self {
191        Self::new(FormatKind::DmsSigned).with_default_precision()
192    }
193
194    ///
195    /// Return a [`FormatOptions`] for labeled degrees, minutes, seconds with the default precision.
196    ///
197    pub const fn dms_labeled() -> Self {
198        Self::new(FormatKind::DmsLabeled).with_default_precision()
199    }
200
201    ///
202    /// Return a [`FormatOptions`] for bare degrees, minutes, seconds with the default precision.
203    ///
204    pub const fn dms_bare() -> Self {
205        Self::new(FormatKind::DmsBare).with_default_precision()
206    }
207
208    ///
209    /// Override the number of fractional digits used when rendering.
210    ///
211    /// # Examples
212    ///
213    /// ```rust
214    /// use lat_long::{Angle, Latitude, fmt::{FormatOptions, Formatter}};
215    ///
216    /// let lat = Latitude::new(48, 51, 29.6).unwrap();
217    /// let s = lat.to_formatted_string(&FormatOptions::decimal().with_precision(2));
218    /// assert_eq!(s, "48.86");
219    /// ```
220    ///
221    pub const fn with_precision(mut self, precision: usize) -> Self {
222        self.precision = Some(precision);
223        self
224    }
225
226    ///
227    /// Set the precision to the default value for the current [`FormatKind`].
228    ///
229    /// Decimal uses [`DEFAULT_DECIMAL_PRECISION`]; every DMS variant uses
230    /// [`DEFAULT_DMS_PRECISION`].
231    ///
232    pub const fn with_default_precision(mut self) -> Self {
233        match self.kind {
234            FormatKind::Decimal => self.precision = Some(DEFAULT_DECIMAL_PRECISION),
235            _ => self.precision = Some(DEFAULT_DMS_PRECISION),
236        }
237        self
238    }
239
240    ///
241    /// Set the `(positive, negative)` label pair used by [`FormatKind::DmsLabeled`].
242    ///
243    /// Prefer [`with_latitude_labels`](Self::with_latitude_labels) or
244    /// [`with_longitude_labels`](Self::with_longitude_labels) for the standard
245    /// `N`/`S` and `E`/`W` pairs.
246    ///
247    pub const fn with_labels(mut self, labels: (char, char)) -> Self {
248        self.labels = Some(labels);
249        self
250    }
251
252    ///
253    /// Convenience: set labels to the latitude pair `('N', 'S')`.
254    ///
255    pub const fn with_latitude_labels(mut self) -> Self {
256        self.labels = Some(('N', 'S'));
257        self
258    }
259
260    ///
261    /// Convenience: set labels to the longitude pair `('E', 'W')`.
262    ///
263    pub const fn with_longitude_labels(mut self) -> Self {
264        self.labels = Some(('E', 'W'));
265        self
266    }
267
268    ///
269    /// Returns the configured [`FormatKind`].
270    ///
271    pub const fn kind(&self) -> FormatKind {
272        self.kind
273    }
274
275    ///
276    /// Returns `true` if this is a [`FormatKind::Decimal`] format.
277    ///
278    pub const fn is_decimal(&self) -> bool {
279        matches!(self.kind(), FormatKind::Decimal)
280    }
281
282    ///
283    /// Returns `true` if this is any DMS variant (signed, labeled, or bare).
284    ///
285    pub const fn is_dms(&self) -> bool {
286        self.is_dms_signed() || self.is_dms_labeled() || self.is_dms_bare()
287    }
288
289    ///
290    /// Returns `true` if this is the [`FormatKind::DmsSigned`] variant.
291    ///
292    pub const fn is_dms_signed(&self) -> bool {
293        matches!(self.kind(), FormatKind::DmsSigned)
294    }
295
296    ///
297    /// Returns `true` if this is the [`FormatKind::DmsLabeled`] variant.
298    ///
299    pub const fn is_dms_labeled(&self) -> bool {
300        matches!(self.kind(), FormatKind::DmsLabeled)
301    }
302
303    ///
304    /// Returns `true` if this is the [`FormatKind::DmsBare`] variant.
305    ///
306    pub const fn is_dms_bare(&self) -> bool {
307        matches!(self.kind(), FormatKind::DmsBare)
308    }
309
310    ///
311    /// Returns the configured precision, if any was set.
312    ///
313    pub const fn precision(&self) -> Option<usize> {
314        self.precision
315    }
316
317    ///
318    /// Returns the configured `(positive, negative)` label pair, if any.
319    ///
320    pub const fn labels(&self) -> Option<(char, char)> {
321        self.labels
322    }
323
324    ///
325    /// Returns just the label used for positive values, if labels are set.
326    ///
327    pub fn positive_label(&self) -> Option<char> {
328        self.labels.as_ref().map(|l| l.0)
329    }
330
331    ///
332    /// Returns just the label used for negative values, if labels are set.
333    ///
334    pub fn negative_label(&self) -> Option<char> {
335        self.labels.as_ref().map(|l| l.1)
336    }
337}
338
339// ---------------------------------------------------------------------------
340// Internal Functions
341// ---------------------------------------------------------------------------
342
343pub(crate) fn formatter_impl<W: Write>(
344    angle: OrderedFloat<f64>,
345    f: &mut W,
346    options: &FormatOptions,
347) -> std::fmt::Result {
348    match options.kind() {
349        FormatKind::Decimal => {
350            if let Some(precision) = options.precision() {
351                write!(f, "{:.precision$}", angle.into_inner())
352            } else {
353                write!(f, "{}", angle.into_inner())
354            }
355        }
356        FormatKind::DmsSigned => {
357            let (degrees, minutes, seconds) = inner::to_degrees_minutes_seconds(angle);
358            if let Some(precision) = options.precision() {
359                write!(f, "{degrees}° {minutes}′ {seconds:.precision$}″")
360            } else {
361                write!(f, "{degrees}° {minutes}′ {seconds}″")
362            }
363        }
364        FormatKind::DmsLabeled => {
365            let (degrees, minutes, seconds) = inner::to_degrees_minutes_seconds(angle);
366            let (positive, negative) = options.labels().expect("No labels provided");
367            if let Some(precision) = options.precision() {
368                write!(
369                    f,
370                    "{}° {}′ {:.precision$}″ {}",
371                    degrees.abs(),
372                    minutes,
373                    seconds,
374                    if angle > inner::ZERO {
375                        positive.to_string()
376                    } else if angle < inner::ZERO {
377                        negative.to_string()
378                    } else {
379                        "".to_string()
380                    }
381                )
382            } else {
383                write!(
384                    f,
385                    "{}° {}′ {}″ {}",
386                    degrees.abs(),
387                    minutes,
388                    seconds,
389                    if angle > inner::ZERO {
390                        positive.to_string()
391                    } else if angle < inner::ZERO {
392                        negative.to_string()
393                    } else {
394                        "".to_string()
395                    }
396                )
397            }
398        }
399        FormatKind::DmsBare => {
400            let (degrees, minutes, seconds) = inner::to_degrees_minutes_seconds(angle);
401            let precision = if let Some(precision) = options.precision()
402                && precision >= 4
403            {
404                precision
405            } else {
406                MINIMUM_DMS_BARE_PRECISION
407            };
408            let width = precision + 3;
409            write!(f, "{degrees:+04}:{minutes:02}:{seconds:0width$.precision$}",)
410        }
411    }
412}
413
414// ---------------------------------------------------------------------------
415// Unit Tests
416// ---------------------------------------------------------------------------
417
418#[cfg(test)]
419mod tests {
420    use crate::fmt::{FormatOptions, Formatter};
421    use ordered_float::OrderedFloat;
422
423    #[test]
424    fn test_float_to_string_positive() {
425        assert_eq!(
426            OrderedFloat(45.508333)
427                .to_formatted_string(&FormatOptions::decimal().with_precision(6)),
428            "45.508333"
429        );
430    }
431
432    #[test]
433    fn test_float_to_string_negative() {
434        assert_eq!(
435            OrderedFloat(-45.508333)
436                .to_formatted_string(&FormatOptions::decimal().with_precision(6)),
437            "-45.508333"
438        );
439    }
440
441    #[test]
442    fn test_float_to_string_signed_positive() {
443        assert_eq!(
444            OrderedFloat(45.508333).to_formatted_string(&FormatOptions::dms_signed()),
445            "45° 30′ 29.998800″"
446        );
447    }
448
449    #[test]
450    fn test_float_to_string_signed_negative() {
451        assert_eq!(
452            OrderedFloat(-45.508333).to_formatted_string(&FormatOptions::dms_signed()),
453            "-45° 30′ 29.998800″"
454        );
455    }
456
457    #[test]
458    fn test_float_to_degree_string_labeled_positive() {
459        assert_eq!(
460            OrderedFloat(45.508333)
461                .to_formatted_string(&FormatOptions::dms_labeled().with_latitude_labels()),
462            "45° 30′ 29.998800″ N"
463        );
464    }
465
466    #[test]
467    fn test_float_to_string_labeled_negative() {
468        assert_eq!(
469            OrderedFloat(-45.508333)
470                .to_formatted_string(&FormatOptions::dms_labeled().with_latitude_labels()),
471            "45° 30′ 29.998800″ S"
472        );
473    }
474
475    #[test]
476    fn test_float_to_string_bare_positive() {
477        assert_eq!(
478            OrderedFloat(45.508333).to_formatted_string(&FormatOptions::dms_bare()),
479            "+045:30:29.998800"
480        );
481    }
482
483    #[test]
484    fn test_float_to_string_bare_negative() {
485        assert_eq!(
486            OrderedFloat(-45.508333).to_formatted_string(&FormatOptions::dms_bare()),
487            "-045:30:29.998800"
488        );
489    }
490}