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