1
use chrono::{DateTime, Datelike, NaiveDate, Utc};
2

            
3
use super::super::PeriodGrouping;
4
use crate::command::CmdError;
5

            
6
/// Periods one report may contain: 600 months is fifty years, well past any
7
/// readable report.
8
///
9
/// The callers run one query per period — and activity runs one *per group* on
10
/// top, which is bounded separately. `%Y-%m-%d` accepts `+262142-12-31`, so
11
/// without a cap one request is 3.1 million periods against a cluster shared
12
/// with other tenants.
13
const MAX_PERIODS: i64 = 600;
14

            
15
/// A period's label and its half-open `[start, end)` bounds.
16
pub(super) type Period = (String, DateTime<Utc>, DateTime<Utc>);
17

            
18
/// The work a report may schedule, given how many periods and groups it has.
19
///
20
/// Extracted so it can be tested directly: the loop that consumes it lives
21
/// inside a `command!` body and needs a database.
22
///
23
/// # Errors
24
/// `CmdError::Args` when the product exceeds `budget`.
25
49
pub(super) fn query_budget(periods: usize, groups: usize, budget: usize) -> Result<(), CmdError> {
26
49
    let queries = periods.max(1) * groups;
27
49
    if queries > budget {
28
3
        return Err(CmdError::Args(format!(
29
3
            "this report would run {queries} queries; the maximum is {budget}"
30
3
        )));
31
46
    }
32
46
    Ok(())
33
49
}
34

            
35
/// Refuses a range that ends before it starts.
36
///
37
/// Every report checks this, grouped or not: an ungrouped reversed range
38
/// otherwise matches no rows and comes back as an empty report under a 200.
39
///
40
/// # Errors
41
/// `CmdError::Args` when `to` is not after `from`.
42
122
pub(super) fn check_range(from: DateTime<Utc>, to: DateTime<Utc>) -> Result<(), CmdError> {
43
122
    if to <= from {
44
6
        return Err(CmdError::Args(
45
6
            "the report range ends before it starts".to_string(),
46
6
        ));
47
116
    }
48
116
    Ok(())
49
122
}
50

            
51
/// Period boundaries for `grouping`, or an error when the range is inverted or
52
/// wider than a report can usefully be.
53
///
54
/// # Errors
55
/// `CmdError::Args` when `to` precedes `from`, or the range exceeds
56
/// [`MAX_PERIODS`] periods.
57
21
pub(super) fn boundaries(
58
21
    grouping: PeriodGrouping,
59
21
    from: DateTime<Utc>,
60
21
    to: DateTime<Utc>,
61
21
) -> Result<Vec<Period>, CmdError> {
62
    // A leap second (`23:59:60`) is representable and compares GREATER than
63
    // `MAX_UTC`, which every `next_*` saturates to — so the generators below
64
    // would step to `MAX_UTC`, still be `< to`, and append periods forever.
65
21
    let to = to.min(DateTime::<Utc>::MAX_UTC);
66
21
    check_range(from, to)?;
67
    // Exactly what the generators below will produce, not an estimate of it:
68
    // they start at `from`'s bucket and step while `current < to`, so a `to`
69
    // that is not itself on a boundary adds one more. Rounding the elapsed
70
    // month count instead both refused 600-month reports and admitted 601
71
    // quarters or years.
72
17
    let periods = unit_index(grouping, to) - unit_index(grouping, from)
73
17
        + i64::from(to > first_of_unit(grouping, to));
74
17
    if periods > MAX_PERIODS {
75
8
        return Err(CmdError::Args(format!(
76
8
            "the report range spans {periods} periods; the maximum is {MAX_PERIODS}"
77
8
        )));
78
9
    }
79

            
80
9
    let mut periods = match grouping {
81
6
        PeriodGrouping::Month => generate_month_boundaries(from, to),
82
1
        PeriodGrouping::Quarter => generate_quarter_boundaries(from, to),
83
2
        PeriodGrouping::Year => generate_year_boundaries(from, to),
84
    };
85

            
86
    // The first bucket is aligned to its calendar unit, so it starts before the
87
    // requested range. The caller queries these bounds directly, so without this
88
    // the report sums transactions the user did not ask for and contradicts the
89
    // `date_from` it reports back.
90
9
    if let Some(first) = periods.first_mut() {
91
9
        first.1 = first.1.max(from);
92
9
    }
93
9
    Ok(periods)
94
21
}
95

            
96
/// Index of `dt`'s calendar bucket, counted in units of `grouping`.
97
34
fn unit_index(grouping: PeriodGrouping, dt: DateTime<Utc>) -> i64 {
98
34
    let year = i64::from(dt.year());
99
34
    match grouping {
100
20
        PeriodGrouping::Month => year * 12 + i64::from(dt.month0()),
101
6
        PeriodGrouping::Quarter => year * 4 + i64::from(dt.month0() / 3),
102
8
        PeriodGrouping::Year => year,
103
    }
104
34
}
105

            
106
17
fn first_of_unit(grouping: PeriodGrouping, dt: DateTime<Utc>) -> DateTime<Utc> {
107
17
    match grouping {
108
10
        PeriodGrouping::Month => first_of_month(dt),
109
3
        PeriodGrouping::Quarter => first_of_quarter(dt),
110
4
        PeriodGrouping::Year => first_of_year(dt),
111
    }
112
17
}
113

            
114
8
pub(super) fn generate_month_boundaries(from: DateTime<Utc>, to: DateTime<Utc>) -> Vec<Period> {
115
8
    let mut periods = Vec::new();
116
8
    let mut current = first_of_month(from);
117
633
    while current < to {
118
625
        let next = next_month(current);
119
625
        let label = current.format("%Y-%m").to_string();
120
625
        periods.push((label, current, next.min(to)));
121
625
        current = next;
122
625
    }
123
8
    periods
124
8
}
125

            
126
3
pub(super) fn generate_quarter_boundaries(from: DateTime<Utc>, to: DateTime<Utc>) -> Vec<Period> {
127
3
    let mut periods = Vec::new();
128
3
    let mut current = first_of_quarter(from);
129
8
    while current < to {
130
5
        let next = next_quarter(current);
131
5
        let q = (current.month0() / 3) + 1;
132
5
        let label = format!("{}-Q{q}", current.year());
133
5
        periods.push((label, current, next.min(to)));
134
5
        current = next;
135
5
    }
136
3
    periods
137
3
}
138

            
139
4
pub(super) fn generate_year_boundaries(from: DateTime<Utc>, to: DateTime<Utc>) -> Vec<Period> {
140
4
    let mut periods = Vec::new();
141
4
    let mut current = first_of_year(from);
142
60
    while current < to {
143
56
        let next = next_year(current);
144
56
        let label = current.year().to_string();
145
56
        periods.push((label, current, next.min(to)));
146
56
        current = next;
147
56
    }
148
4
    periods
149
4
}
150

            
151
/// Midnight UTC on the first of the given month.
152
///
153
/// Saturates to `MAX_UTC`, which is what makes the generators terminate: the
154
/// `next_*` helpers add a year, so a request bounded near `NaiveDate::MAX`
155
/// (year 262142 — and `%Y-%m-%d` parses `+262142-12-01` straight from a query
156
/// parameter) has no representable successor. Any earlier value here walks the
157
/// `while current < to` loops backwards and they never finish.
158
718
fn month_start(year: i32, month: u32) -> DateTime<Utc> {
159
718
    NaiveDate::from_ymd_opt(year, month, 1)
160
718
        .and_then(|d| d.and_hms_opt(0, 0, 0))
161
718
        .map_or(DateTime::<Utc>::MAX_UTC, |dt| dt.and_utc())
162
718
}
163

            
164
18
fn first_of_month(dt: DateTime<Utc>) -> DateTime<Utc> {
165
18
    month_start(dt.year(), dt.month())
166
18
}
167

            
168
625
fn next_month(dt: DateTime<Utc>) -> DateTime<Utc> {
169
625
    if dt.month() == 12 {
170
53
        month_start(dt.year() + 1, 1)
171
    } else {
172
572
        month_start(dt.year(), dt.month() + 1)
173
    }
174
625
}
175

            
176
6
fn first_of_quarter(dt: DateTime<Utc>) -> DateTime<Utc> {
177
6
    month_start(dt.year(), (dt.month0() / 3) * 3 + 1)
178
6
}
179

            
180
5
fn next_quarter(dt: DateTime<Utc>) -> DateTime<Utc> {
181
5
    let qm = (dt.month0() / 3) * 3 + 1;
182
5
    if qm + 3 > 12 {
183
1
        month_start(dt.year() + 1, qm + 3 - 12)
184
    } else {
185
4
        month_start(dt.year(), qm + 3)
186
    }
187
5
}
188

            
189
8
fn first_of_year(dt: DateTime<Utc>) -> DateTime<Utc> {
190
8
    month_start(dt.year(), 1)
191
8
}
192

            
193
56
fn next_year(dt: DateTime<Utc>) -> DateTime<Utc> {
194
56
    month_start(dt.year() + 1, 1)
195
56
}