rust_persian_tools/time_diff/
mod.rs

1use chrono::{DateTime, Local, NaiveDateTime, TimeZone};
2use thiserror::Error;
3
4use crate::digits::{DigitsEn2Ar, DigitsEn2Fa};
5
6pub(crate) const MINUTE: i64 = 60;
7pub(crate) const HOUR: i64 = MINUTE * 60;
8pub(crate) const DAY: i64 = HOUR * 24;
9pub(crate) const MONTH: i64 = DAY * 30;
10pub(crate) const YEAR: i64 = DAY * 365;
11
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13#[derive(Error, Clone, Copy, Debug, Hash, PartialEq, Eq)]
14pub enum TimeAgoError {
15    #[error("Wrong datetime format !")]
16    InvalidDateTimeFormat,
17    #[error("Unexpected error happened !")]
18    Unknown,
19}
20
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22#[derive(Clone, Debug, Hash, PartialEq, Eq)]
23pub enum Timestamp {
24    String(String),
25    Integer(i64),
26}
27
28impl From<String> for Timestamp {
29    fn from(datetime_str: String) -> Self {
30        Timestamp::String(datetime_str)
31    }
32}
33
34impl From<&str> for Timestamp {
35    fn from(datetime_str: &str) -> Self {
36        Timestamp::String(datetime_str.to_string())
37    }
38}
39
40impl From<i64> for Timestamp {
41    fn from(timestamp: i64) -> Self {
42        Timestamp::Integer(timestamp)
43    }
44}
45
46/// The [TimeDiff] stuct has two main methods: `short_form()` & `long_form()` \
47/// the `short_form()` returns a short desciption about time diffrence\
48/// - 5 دقیقه قبل
49/// - حدود 2 هفته بعد
50///
51/// the `long_form()` returns a long and exact desciption about time diffrence\
52/// - 6 سال و 6 ماه و 10 روز و 12 دقیقه و 37 ثانیه بعد
53///
54/// also there are more methods to return long_from and short with arabic or persian digits
55/// - short_form_fa_digits()
56/// - short_form_ar_digits()
57/// - long_form_fa_digits()
58/// - long_form_ar_digits()
59#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60#[derive(PartialEq, Eq, Debug, Clone, Copy, Hash)]
61pub struct TimeDiff {
62    pub years: u32,
63    pub months: u8,
64    pub days: u8,
65    pub hours: u8,
66    pub minutes: u8,
67    pub seconds: u8,
68    pub is_future: bool,
69}
70
71impl TimeDiff {
72    pub fn long_form(&self) -> String {
73        let mut periods: Vec<String> = Vec::new();
74
75        let pre_or_next = self.pre_or_next();
76
77        if self.years > 0 {
78            periods.push(format!("{} سال", self.years))
79        }
80        if self.months > 0 {
81            periods.push(format!("{} ماه", self.months))
82        }
83        if self.days > 0 {
84            periods.push(format!("{} روز", self.days))
85        }
86        if self.hours > 0 {
87            periods.push(format!("{} ساعت", self.hours))
88        }
89        if self.minutes > 0 {
90            periods.push(format!("{} دقیقه", self.minutes))
91        }
92        if self.seconds > 0 {
93            periods.push(format!("{} ثانیه", self.seconds))
94        }
95
96        format!("{} {}", periods.join(" و "), pre_or_next)
97    }
98
99    pub fn short_form(&self) -> String {
100        let pre_or_next = self.pre_or_next();
101
102        if self.years != 0 {
103            format!("{} {} {} {}", "حدود", &self.years, "سال", pre_or_next)
104        } else if self.months != 0 {
105            format!("{} {} {} {}", "حدود", &self.months, "ماه", pre_or_next)
106        } else if self.days > 7 {
107            format!("{} {} {} {}", "حدود", &self.days / 7, "هفته", pre_or_next)
108        } else if self.days != 0 {
109            format!("{} {} {} {}", "حدود", &self.days, "روز", pre_or_next)
110        } else if self.hours != 0 {
111            format!("{} {} {}", &self.hours, "ساعت", pre_or_next)
112        } else if self.minutes != 0 {
113            format!("{} {} {}", &self.minutes, "دقیقه", pre_or_next)
114        } else if self.seconds != 0 {
115            format!("{} {} {}", &self.seconds, "ثانیه", pre_or_next)
116        } else {
117            "اکنون".to_string()
118        }
119    }
120
121    pub fn short_form_fa_digits(&self) -> String {
122        self.short_form().digits_en_to_fa()
123    }
124
125    pub fn long_form_fa_digits(&self) -> String {
126        self.long_form().digits_en_to_fa()
127    }
128
129    pub fn short_form_ar_digits(&self) -> String {
130        self.short_form().digits_en_to_ar()
131    }
132
133    pub fn long_form_ar_digits(&self) -> String {
134        self.long_form().digits_en_to_ar()
135    }
136
137    pub fn pre_or_next(&self) -> String {
138        if self.is_future {
139            "بعد".to_owned()
140        } else {
141            "قبل".to_owned()
142        }
143    }
144}
145
146/// Converts a valid datetime to timestamp
147///
148/// # Warning
149/// This function is designed to only works for these date time formats :
150///
151///
152/// - `%Y-%m-%d %H:%M:%S`: Sortable format
153/// - `%Y/%m/%d %H:%M:%S`: Sortable format
154/// - `%Y-%m-%dT%H:%M:%S%:z`: ISO 8601 with timezone offset
155/// - `%Y-%m-%dT%H:%M:%S%.3f%:z`: ISO 8601 with milliseconds and timezone offset
156/// - `%a, %d %b %Y %H:%M:%S %z`: RFC 2822 Format
157///
158///
159///  timezone is set with the current timezone of the OS.
160///
161/// # Examples
162///
163/// ```
164/// use rust_persian_tools::time_diff::convert_to_timestamp;
165///
166/// assert!(convert_to_timestamp("2023/12/30 12:21:13").is_ok());
167/// assert!(convert_to_timestamp("2023/12/30 25:21:13").is_err());
168/// ```
169pub fn convert_to_timestamp(datetime: impl AsRef<str>) -> Result<i64, TimeAgoError> {
170    let datetime = datetime.as_ref();
171    let date_obj = get_date_time(datetime)?;
172
173    Ok(date_obj.timestamp())
174}
175
176/// Converts datetime to Chrono `DateTime<Local>`
177///
178/// # Warning
179/// This function is designed to only works for these date time formats :
180///
181/// - `%Y-%m-%d %H:%M:%S`: Sortable format
182/// - `%Y/%m/%d %H:%M:%S`: Sortable format
183/// - `%Y-%m-%dT%H:%M:%S%:z`: ISO 8601 with timezone offset
184/// - `%Y-%m-%dT%H:%M:%S%.3f%:z`: ISO 8601 with milliseconds and timezone offset
185/// - `%a, %d %b %Y %H:%M:%S %z`: RFC 2822 Format
186///
187///  timezone is set with the current timezone of the OS.
188///
189/// # Examples
190///
191/// ```
192/// use rust_persian_tools::time_diff::get_date_time;
193///
194/// assert!(get_date_time("2019/03/18 12:22:14").is_ok());
195/// assert!(get_date_time("20192/03/18 12:22:14").is_err());
196/// ```
197pub fn get_date_time(datetime: impl AsRef<str>) -> Result<DateTime<Local>, TimeAgoError> {
198    let datetime = datetime.as_ref();
199
200    let formats = [
201        "%Y-%m-%d %H:%M:%S",        // Sortable format
202        "%Y/%m/%d %H:%M:%S",        // Sortable format
203        "%Y-%m-%dT%H:%M:%S%:z",     // ISO 8601 with timezone offset
204        "%Y-%m-%dT%H:%M:%S%.3f%:z", // ISO 8601 with milliseconds and timezone offset
205        "%a, %d %b %Y %H:%M:%S %z", // RFC 2822 Format
206    ];
207
208    for format in formats {
209        if let Ok(parsed) = NaiveDateTime::parse_from_str(datetime, format) {
210            // Successfully parsed, convert to timestamp
211            let datetime_with_timezone = Local.from_local_datetime(&parsed).earliest();
212            return match datetime_with_timezone {
213                Some(local_date_time) => Ok(local_date_time),
214                None => Err(TimeAgoError::Unknown),
215            };
216        }
217    }
218
219    Err(TimeAgoError::InvalidDateTimeFormat)
220}
221
222/// Returns current timestamp
223///
224/// # Warning
225///
226///  timezone is set with the current timezone of the OS.
227///
228pub fn get_current_timestamp() -> i64 {
229    let now = Local::now();
230    now.timestamp()
231}
232
233/// datetime argument can be a integer as timestamp or a string as datetime
234/// Returns a [TimeDiff] struct based on how much time is remaining or passed based on the givin datetime\
235/// The [TimeDiff] struct has two methods , `short_form()` & `long_form()` \
236///
237/// the `short_form()` returns a short description about time difference\
238/// - 5 دقیقه قبل
239/// - حدود 2 هفته بعد
240///
241/// the `long_form()` returns a long and exact description about time difference\
242/// - 6 سال و 6 ماه و 10 روز و 12 دقیقه و 37 ثانیه بعد
243///
244/// also there are some other methods like `short_form_fa_digits()` or `short_form_ar_digits()` that is the same as `short_form()` but with farsi or arabic digits
245///
246/// # Warning
247/// This function is designed to only works for these date time formats if you send datetime argument as datetime string :
248///
249/// - `%Y-%m-%d %H:%M:%S`: Sortable format
250/// - `%Y/%m/%d %H:%M:%S`: Sortable format
251/// - `%Y-%m-%dT%H:%M:%S%:z`: ISO 8601 with timezone offset
252/// - `%Y-%m-%dT%H:%M:%S%.3f%:z`: ISO 8601 with milliseconds and timezone offset
253/// - `%a, %d %b %Y %H:%M:%S %z`: RFC 2822 Format
254///
255///  timezone is set with the current timezone of the OS.
256///
257/// # Examples
258///
259/// ```
260/// use rust_persian_tools::time_diff::{TimeDiff , time_diff_now};
261/// use chrono::{Duration,Local};
262///
263/// let current_time = Local::now();
264/// let due_date = current_time
265/// + Duration::try_weeks(320).unwrap()
266/// + Duration::try_hours(7).unwrap()
267/// + Duration::try_minutes(13).unwrap()
268/// + Duration::try_seconds(37).unwrap();
269/// let formatted_time = due_date.format("%Y-%m-%d %H:%M:%S").to_string();
270/// assert_eq!(
271/// time_diff_now(formatted_time).unwrap(),
272///   TimeDiff {
273///       years: 6,
274///       months: 1,
275///       days: 20,
276///       hours: 7,
277///       minutes: 13,
278///       seconds: 37,
279///       is_future: true,
280///   }
281/// );
282///
283/// // Example with short_form()
284/// let current_time = Local::now();
285/// let ten_minutes_ago = current_time - Duration::try_minutes(10).unwrap();
286/// let formatted_time = ten_minutes_ago.format("%Y-%m-%d %H:%M:%S").to_string(); // create datetime string from 10 minutes ago
287/// assert!(time_diff_now(formatted_time).is_ok_and(|datetime| datetime.short_form() == "10 دقیقه قبل"));
288/// ```
289pub fn time_diff_now(datetime: impl Into<Timestamp>) -> Result<TimeDiff, TimeAgoError> {
290    let ts_now = get_current_timestamp();
291    let ts = match datetime.into() {
292        Timestamp::String(datetime_str) => convert_to_timestamp(datetime_str)?,
293        Timestamp::Integer(timestamp) => timestamp,
294    };
295
296    let timestamp_diff = ts - ts_now;
297
298    Ok(get_time_diff(timestamp_diff))
299}
300
301/// start & end arguments can be a integer as timestamp or a string as datetime
302/// Returns a [TimeDiff] struct based on how much time is remaining or passed based on the diffrence between two datetime\
303/// The [TimeDiff] struct has two main methods , `short_form()` & `long_form()` \
304/// the `short_form()` returns a short description about time difference\
305/// - 5 دقیقه قبل
306/// - حدود 2 هفته بعد
307///
308/// the `long_form()` returns a long and exact description about time difference\
309/// - 6 سال و 6 ماه و 10 روز و 12 دقیقه و 37 ثانیه بعد
310///
311/// also there are some other methods like `short_form_fa_digits()` or `short_form_ar_digits()` that is the same as `short_form()` but with farsi or arabic digits
312///
313/// # Warning
314/// This function is designed to only works for these datetime formats if you send start or end as datetime string:
315///
316/// - `%Y-%m-%d %H:%M:%S`: Sortable format
317/// - `%Y/%m/%d %H:%M:%S`: Sortable format
318/// - `%Y-%m-%dT%H:%M:%S%:z`: ISO 8601 with timezone offset
319/// - `%Y-%m-%dT%H:%M:%S%.3f%:z`: ISO 8601 with milliseconds and timezone offset
320/// - `%a, %d %b %Y %H:%M:%S %z`: RFC 2822 Format
321///
322///  timezone is set with the current timezone of the OS.
323///
324/// # Examples
325///
326/// ```
327/// use rust_persian_tools::time_diff::{TimeDiff , time_diff_between};
328/// use chrono::{Duration,Local};
329///
330/// let current_time = Local::now();
331/// let start = current_time
332///     + Duration::try_weeks(320).unwrap()
333///     + Duration::try_hours(7).unwrap()
334///     + Duration::try_minutes(13).unwrap()
335///     + Duration::try_seconds(37).unwrap();
336/// let end = (current_time + Duration::try_weeks(150).unwrap() + Duration::try_hours(4).unwrap()).timestamp();
337/// let formatted_time = start.format("%Y-%m-%d %H:%M:%S").to_string();
338/// assert_eq!(
339///     time_diff_between(formatted_time, end).unwrap(),
340///     TimeDiff {
341///         years: 3,
342///         months: 3,
343///         days: 5,
344///         hours: 3,
345///         minutes: 13,
346///         seconds: 37,
347///         is_future: false,
348///     }
349/// );
350///
351/// // Example with long_form() with persian digits
352//  let current_time = Local::now();
353//  let start = current_time
354//     + Duration::weeks(320)
355//     + Duration::hours(7)
356//     + Duration::minutes(13)
357//     + Duration::seconds(37);
358//
359// let end = (current_time + Duration::weeks(150) + Duration::hours(4)).timestamp();
360//
361// let formatted_time = start.format("%Y-%m-%d %H:%M:%S").to_string();
362// assert_eq!(
363//     time_diff_between(formatted_time, end)
364//         .unwrap()
365//         .long_form_fa_digits(),
366//     "۳ سال و ۳ ماه و ۵ روز و ۳ ساعت و ۱۳ دقیقه و ۳۷ ثانیه قبل"
367// );
368/// ```
369pub fn time_diff_between(
370    start: impl Into<Timestamp>,
371    end: impl Into<Timestamp>,
372) -> Result<TimeDiff, TimeAgoError> {
373    let ts_start = match start.into() {
374        Timestamp::String(datetime_str) => convert_to_timestamp(datetime_str)?,
375        Timestamp::Integer(timestamp) => timestamp,
376    };
377
378    let ts_end = match end.into() {
379        Timestamp::String(datetime_str) => convert_to_timestamp(datetime_str)?,
380        Timestamp::Integer(timestamp) => timestamp,
381    };
382
383    let timestamp_diff = ts_end - ts_start;
384
385    Ok(get_time_diff(timestamp_diff))
386}
387
388fn get_time_diff(timestamp_diff: i64) -> TimeDiff {
389    let is_future = timestamp_diff > 0;
390
391    let mut timestamp_diff = timestamp_diff.abs();
392
393    let years: u32 = (timestamp_diff / YEAR) as u32;
394    timestamp_diff %= YEAR;
395
396    let months: u8 = ((timestamp_diff / MONTH) % MONTH) as u8;
397    timestamp_diff %= MONTH;
398
399    let days: u8 = ((timestamp_diff / DAY) % DAY) as u8;
400    timestamp_diff %= DAY;
401
402    let hours: u8 = ((timestamp_diff / HOUR) % HOUR) as u8;
403    timestamp_diff %= HOUR;
404
405    let minutes: u8 = ((timestamp_diff / MINUTE) % MINUTE) as u8;
406    timestamp_diff %= MINUTE;
407
408    let seconds: u8 = timestamp_diff as u8;
409
410    TimeDiff {
411        years,
412        months,
413        days,
414        hours,
415        minutes,
416        seconds,
417        is_future,
418    }
419}
420
421#[cfg(test)]
422mod tests {
423
424    use super::*;
425    use chrono::Duration;
426
427    #[test]
428    fn test_time_diff_now() {
429        let current_time = Local::now();
430        // let ten_minutes_ago = current_time - Duration::minutes(10);
431        let formatted_time = current_time.format("%Y-%m-%dT%H:%M:%S%:z").to_string();
432
433        assert!(
434            time_diff_now(formatted_time).is_ok_and(|datetime| datetime.short_form() == "اکنون")
435        );
436    }
437
438    #[test]
439    fn test_time_diff_10_min_ago() {
440        let current_time = Local::now();
441        let ten_minutes_ago = current_time - Duration::try_minutes(10).unwrap();
442        let formatted_time = ten_minutes_ago.format("%Y-%m-%d %H:%M:%S").to_string();
443
444        // dbg!(time_diff(&formatted_time))
445        assert!(time_diff_now(formatted_time)
446            .is_ok_and(|datetime| datetime.short_form() == "10 دقیقه قبل"));
447    }
448
449    #[test]
450    fn test_time_diff_between_to_datetime() {
451        let current_time = Local::now();
452        let start = current_time
453            + Duration::try_weeks(320).unwrap()
454            + Duration::try_hours(7).unwrap()
455            + Duration::try_minutes(13).unwrap()
456            + Duration::try_seconds(37).unwrap();
457
458        let end =
459            (current_time + Duration::try_weeks(150).unwrap() + Duration::try_hours(4).unwrap())
460                .timestamp();
461
462        let formatted_time = start.format("%Y-%m-%d %H:%M:%S").to_string();
463        assert_eq!(
464            time_diff_between(formatted_time, end).unwrap(),
465            TimeDiff {
466                years: 3,
467                months: 3,
468                days: 5,
469                hours: 3,
470                minutes: 13,
471                seconds: 37,
472                is_future: false,
473            }
474        );
475    }
476
477    #[test]
478    fn test_time_diff_between_to_datetime_with_long_format_persian_digits() {
479        let current_time = Local::now();
480        let start = current_time
481            + Duration::try_weeks(320).unwrap()
482            + Duration::try_hours(7).unwrap()
483            + Duration::try_minutes(13).unwrap()
484            + Duration::try_seconds(37).unwrap();
485
486        let end =
487            (current_time + Duration::try_weeks(150).unwrap() + Duration::try_hours(4).unwrap())
488                .timestamp();
489
490        let formatted_time = start.format("%Y-%m-%d %H:%M:%S").to_string();
491        assert_eq!(
492            time_diff_between(formatted_time, end)
493                .unwrap()
494                .long_form_fa_digits(),
495            "۳ سال و ۳ ماه و ۵ روز و ۳ ساعت و ۱۳ دقیقه و ۳۷ ثانیه قبل"
496        );
497    }
498
499    #[test]
500    fn test_time_diff_next_2_weeks() {
501        let current_time = Local::now();
502        let ten_minutes_ago = current_time + Duration::try_weeks(2).unwrap();
503        let formatted_time = ten_minutes_ago
504            .format("%a, %d %b %Y %H:%M:%S %z")
505            .to_string();
506
507        assert!(time_diff_now(formatted_time)
508            .is_ok_and(|datetime| datetime.short_form() == "حدود 2 هفته بعد"));
509    }
510
511    #[test]
512    fn test_time_diff_next_3_months() {
513        let current_time = Local::now();
514        let ten_minutes_ago = current_time + Duration::try_days(31 * 3).unwrap();
515        let formatted_time = ten_minutes_ago
516            .format("%Y-%m-%dT%H:%M:%S%.3f%:z")
517            .to_string();
518
519        assert!(time_diff_now(formatted_time)
520            .is_ok_and(|datetime| datetime.short_form() == "حدود 3 ماه بعد"));
521    }
522
523    #[test]
524    fn test_time_diff_as_struct() {
525        let current_time = Local::now();
526        let due_date = current_time
527            + Duration::try_weeks(320).unwrap()
528            + Duration::try_hours(7).unwrap()
529            + Duration::try_minutes(13).unwrap()
530            + Duration::try_seconds(37).unwrap();
531        let formatted_time = due_date.format("%Y-%m-%d %H:%M:%S").to_string();
532
533        assert_eq!(
534            time_diff_now(formatted_time).unwrap(),
535            TimeDiff {
536                years: 6,
537                months: 1,
538                days: 20,
539                hours: 7,
540                minutes: 13,
541                seconds: 37,
542                is_future: true,
543            }
544        );
545    }
546
547    #[test]
548    fn test_time_diff_as_long_form() {
549        let current_time = Local::now();
550        let due_date = current_time
551            + Duration::try_weeks(340).unwrap()
552            + Duration::try_minutes(12).unwrap()
553            + Duration::try_seconds(37).unwrap();
554        let formatted_time = due_date.format("%Y-%m-%d %H:%M:%S").to_string();
555
556        assert_eq!(
557            time_diff_now(formatted_time).unwrap().long_form(),
558            String::from("6 سال و 6 ماه و 10 روز و 12 دقیقه و 37 ثانیه بعد")
559        );
560    }
561
562    #[test]
563    fn test_check_valid_date_time() {
564        assert!(get_date_time("2019/03/18 12:22:14").is_ok());
565        assert!(get_date_time("20192/03/18 12:22:14").is_err());
566    }
567}