format_distance.rs

  1// This won't be documented further as it is intended to be removed, or merged with the `time_format` crate.
  2#![allow(missing_docs)]
  3
  4use chrono::{DateTime, Local, NaiveDateTime};
  5
  6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
  7pub enum DateTimeType {
  8    Naive(NaiveDateTime),
  9    Local(DateTime<Local>),
 10}
 11
 12impl DateTimeType {
 13    /// Converts the [`DateTimeType`] to a [`NaiveDateTime`].
 14    ///
 15    /// If the [`DateTimeType`] is already a [`NaiveDateTime`], it will be returned as is.
 16    /// If the [`DateTimeType`] is a [`DateTime<Local>`], it will be converted to a [`NaiveDateTime`].
 17    pub fn to_naive(&self) -> NaiveDateTime {
 18        match self {
 19            DateTimeType::Naive(naive) => *naive,
 20            DateTimeType::Local(local) => local.naive_local(),
 21        }
 22    }
 23}
 24
 25pub struct FormatDistance {
 26    date: DateTimeType,
 27    base_date: DateTimeType,
 28    include_seconds: bool,
 29    add_suffix: bool,
 30    hide_prefix: bool,
 31}
 32
 33impl FormatDistance {
 34    pub fn new(date: DateTimeType, base_date: DateTimeType) -> Self {
 35        Self {
 36            date,
 37            base_date,
 38            include_seconds: false,
 39            add_suffix: false,
 40            hide_prefix: false,
 41        }
 42    }
 43
 44    pub fn from_now(date: DateTimeType) -> Self {
 45        Self::new(date, DateTimeType::Local(Local::now()))
 46    }
 47
 48    pub fn include_seconds(mut self, include_seconds: bool) -> Self {
 49        self.include_seconds = include_seconds;
 50        self
 51    }
 52
 53    pub fn add_suffix(mut self, add_suffix: bool) -> Self {
 54        self.add_suffix = add_suffix;
 55        self
 56    }
 57
 58    pub fn hide_prefix(mut self, hide_prefix: bool) -> Self {
 59        self.hide_prefix = hide_prefix;
 60        self
 61    }
 62}
 63
 64impl std::fmt::Display for FormatDistance {
 65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 66        write!(
 67            f,
 68            "{}",
 69            format_distance(
 70                self.date,
 71                self.base_date.to_naive(),
 72                self.include_seconds,
 73                self.add_suffix,
 74                self.hide_prefix,
 75            )
 76        )
 77    }
 78}
 79/// Calculates the distance in seconds between two [`NaiveDateTime`] objects.
 80/// It returns a signed integer denoting the difference. If `date` is earlier than `base_date`, the returned value will be negative.
 81///
 82/// ## Arguments
 83///
 84/// * `date` - A [NaiveDateTime`] object representing the date of interest
 85/// * `base_date` - A [NaiveDateTime`] object representing the base date against which the comparison is made
 86fn distance_in_seconds(date: NaiveDateTime, base_date: NaiveDateTime) -> i64 {
 87    let duration = date.signed_duration_since(base_date);
 88    -duration.num_seconds()
 89}
 90
 91/// Generates a string describing the time distance between two dates in a human-readable way.
 92fn distance_string(
 93    distance: i64,
 94    include_seconds: bool,
 95    add_suffix: bool,
 96    hide_prefix: bool,
 97) -> String {
 98    let suffix = if distance < 0 { " from now" } else { " ago" };
 99
100    let distance = distance.abs();
101
102    let minutes = distance / 60;
103    let hours = distance / 3_600;
104    let days = distance / 86_400;
105    let months = distance / 2_592_000;
106
107    let string = if distance < 5 && include_seconds {
108        if hide_prefix {
109            "5 seconds"
110        } else {
111            "less than 5 seconds"
112        }
113        .to_string()
114    } else if distance < 10 && include_seconds {
115        if hide_prefix {
116            "10 seconds"
117        } else {
118            "less than 10 seconds"
119        }
120        .to_string()
121    } else if distance < 20 && include_seconds {
122        if hide_prefix {
123            "20 seconds"
124        } else {
125            "less than 20 seconds"
126        }
127        .to_string()
128    } else if distance < 40 && include_seconds {
129        "half a minute".to_string()
130    } else if distance < 60 && include_seconds {
131        if hide_prefix {
132            "a minute"
133        } else {
134            "less than a minute"
135        }
136        .to_string()
137    } else if distance < 90 && include_seconds {
138        "1 minute".to_string()
139    } else if distance < 30 {
140        if hide_prefix {
141            "a minute"
142        } else {
143            "less than a minute"
144        }
145        .to_string()
146    } else if distance < 90 {
147        "1 minute".to_string()
148    } else if distance < 2_700 {
149        format!("{} minutes", minutes)
150    } else if distance < 5_400 {
151        if hide_prefix {
152            "1 hour"
153        } else {
154            "about 1 hour"
155        }
156        .to_string()
157    } else if distance < 86_400 {
158        if hide_prefix {
159            format!("{} hours", hours)
160        } else {
161            format!("about {} hours", hours)
162        }
163        .to_string()
164    } else if distance < 172_800 {
165        "1 day".to_string()
166    } else if distance < 2_592_000 {
167        format!("{} days", days)
168    } else if distance < 5_184_000 {
169        if hide_prefix {
170            "1 month"
171        } else {
172            "about 1 month"
173        }
174        .to_string()
175    } else if distance < 7_776_000 {
176        if hide_prefix {
177            "2 months"
178        } else {
179            "about 2 months"
180        }
181        .to_string()
182    } else if distance < 31_540_000 {
183        format!("{} months", months)
184    } else if distance < 39_425_000 {
185        if hide_prefix {
186            "1 year"
187        } else {
188            "about 1 year"
189        }
190        .to_string()
191    } else if distance < 55_195_000 {
192        if hide_prefix { "1 year" } else { "over 1 year" }.to_string()
193    } else if distance < 63_080_000 {
194        if hide_prefix {
195            "2 years"
196        } else {
197            "almost 2 years"
198        }
199        .to_string()
200    } else {
201        let years = distance / 31_536_000;
202        let remaining_months = (distance % 31_536_000) / 2_592_000;
203
204        if remaining_months < 3 {
205            if hide_prefix {
206                format!("{} years", years)
207            } else {
208                format!("about {} years", years)
209            }
210            .to_string()
211        } else if remaining_months < 9 {
212            if hide_prefix {
213                format!("{} years", years)
214            } else {
215                format!("over {} years", years)
216            }
217            .to_string()
218        } else {
219            if hide_prefix {
220                format!("{} years", years + 1)
221            } else {
222                format!("almost {} years", years + 1)
223            }
224            .to_string()
225        }
226    };
227
228    if add_suffix {
229        format!("{}{}", string, suffix)
230    } else {
231        string
232    }
233}
234
235/// Get the time difference between two dates into a relative human readable string.
236///
237/// For example, "less than a minute ago", "about 2 hours ago", "3 months from now", etc.
238///
239/// Use [`format_distance_from_now`] to compare a NaiveDateTime against now.
240pub fn format_distance(
241    date: DateTimeType,
242    base_date: NaiveDateTime,
243    include_seconds: bool,
244    add_suffix: bool,
245    hide_prefix: bool,
246) -> String {
247    let distance = distance_in_seconds(date.to_naive(), base_date);
248
249    distance_string(distance, include_seconds, add_suffix, hide_prefix)
250}
251
252/// Get the time difference between a date and now as relative human readable string.
253///
254/// For example, "less than a minute ago", "about 2 hours ago", "3 months from now", etc.
255pub fn format_distance_from_now(
256    datetime: DateTimeType,
257    include_seconds: bool,
258    add_suffix: bool,
259    hide_prefix: bool,
260) -> String {
261    let now = chrono::offset::Local::now().naive_local();
262
263    format_distance(datetime, now, include_seconds, add_suffix, hide_prefix)
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use chrono::NaiveDateTime;
270
271    #[test]
272    fn test_format_distance() {
273        let date = DateTimeType::Naive(
274            #[allow(deprecated)]
275            NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
276        );
277        let base_date = DateTimeType::Naive(
278            #[allow(deprecated)]
279            NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
280        );
281
282        assert_eq!(
283            "about 2 hours",
284            format_distance(date, base_date.to_naive(), false, false, false)
285        );
286    }
287
288    #[test]
289    fn test_format_distance_with_suffix() {
290        let date = DateTimeType::Naive(
291            #[allow(deprecated)]
292            NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
293        );
294        let base_date = DateTimeType::Naive(
295            #[allow(deprecated)]
296            NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
297        );
298
299        assert_eq!(
300            "about 2 hours from now",
301            format_distance(date, base_date.to_naive(), false, true, false)
302        );
303    }
304
305    #[test]
306    fn test_format_distance_from_hms() {
307        let date = DateTimeType::Naive(
308            NaiveDateTime::parse_from_str("1969-07-20T11:22:33Z", "%Y-%m-%dT%H:%M:%SZ")
309                .expect("Invalid NaiveDateTime for date"),
310        );
311        let base_date = DateTimeType::Naive(
312            NaiveDateTime::parse_from_str("2024-02-01T00:00:00Z", "%Y-%m-%dT%H:%M:%SZ")
313                .expect("Invalid NaiveDateTime for base_date"),
314        );
315
316        assert_eq!(
317            "over 54 years ago",
318            format_distance(date, base_date.to_naive(), false, true, false)
319        );
320    }
321
322    #[test]
323    fn test_format_distance_string() {
324        assert_eq!(
325            distance_string(3, false, false, false),
326            "less than a minute"
327        );
328        assert_eq!(
329            distance_string(7, false, false, false),
330            "less than a minute"
331        );
332        assert_eq!(
333            distance_string(13, false, false, false),
334            "less than a minute"
335        );
336        assert_eq!(
337            distance_string(21, false, false, false),
338            "less than a minute"
339        );
340        assert_eq!(distance_string(45, false, false, false), "1 minute");
341        assert_eq!(distance_string(61, false, false, false), "1 minute");
342        assert_eq!(distance_string(1920, false, false, false), "32 minutes");
343        assert_eq!(distance_string(3902, false, false, false), "about 1 hour");
344        assert_eq!(distance_string(18002, false, false, false), "about 5 hours");
345        assert_eq!(distance_string(86470, false, false, false), "1 day");
346        assert_eq!(distance_string(345880, false, false, false), "4 days");
347        assert_eq!(
348            distance_string(2764800, false, false, false),
349            "about 1 month"
350        );
351        assert_eq!(
352            distance_string(5184000, false, false, false),
353            "about 2 months"
354        );
355        assert_eq!(distance_string(10368000, false, false, false), "4 months");
356        assert_eq!(
357            distance_string(34694000, false, false, false),
358            "about 1 year"
359        );
360        assert_eq!(
361            distance_string(47310000, false, false, false),
362            "over 1 year"
363        );
364        assert_eq!(
365            distance_string(61503000, false, false, false),
366            "almost 2 years"
367        );
368        assert_eq!(
369            distance_string(160854000, false, false, false),
370            "about 5 years"
371        );
372        assert_eq!(
373            distance_string(236550000, false, false, false),
374            "over 7 years"
375        );
376        assert_eq!(
377            distance_string(249166000, false, false, false),
378            "almost 8 years"
379        );
380    }
381
382    #[test]
383    fn test_format_distance_string_include_seconds() {
384        assert_eq!(
385            distance_string(3, true, false, false),
386            "less than 5 seconds"
387        );
388        assert_eq!(
389            distance_string(7, true, false, false),
390            "less than 10 seconds"
391        );
392        assert_eq!(
393            distance_string(13, true, false, false),
394            "less than 20 seconds"
395        );
396        assert_eq!(distance_string(21, true, false, false), "half a minute");
397        assert_eq!(
398            distance_string(45, true, false, false),
399            "less than a minute"
400        );
401        assert_eq!(distance_string(61, true, false, false), "1 minute");
402    }
403}