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}