1
use axum::http::HeaderMap;
2
use chrono::{DateTime, Datelike, Utc};
3
use num_rational::Rational64;
4
use serde::{Deserialize, Deserializer};
5
use server::command::{
6
    CmdResult, FilterEntity, FinanceEntity, ReportFilter, commodity::ListCommodities,
7
};
8
use sqlx::types::{Uuid, chrono::NaiveDate};
9

            
10
pub mod activity;
11
pub mod balance;
12
pub mod category_breakdown;
13

            
14
use server::command::report::view;
15
use view::AmountView;
16

            
17
pub struct CommodityOption {
18
    pub id: Uuid,
19
    pub symbol: String,
20
    pub name: String,
21
}
22

            
23
/// A single card in the top-of-report summary strip. `is_net` flags cards
24
/// that show a signed net (e.g. Income − Expense) so templates can
25
/// style them distinctly; `highlight` is a generic accent slot.
26
pub struct SummaryCard {
27
    pub label: String,
28
    pub amounts: Vec<AmountView>,
29
    pub is_net: bool,
30
    pub highlight: bool,
31
}
32

            
33
/// Query-string fields shared by every report page to drive client-side
34
/// table controls. The server only needs to read `commodity_columns` (which
35
/// drives a template pivot) and forward `collapsed_depth` into the template
36
/// for initial fold state; `sort_by` is consumed purely by the frontend
37
/// WASM reorder logic, so the Rust handler does not parse it.
38
#[derive(Default, Deserialize)]
39
pub struct TableControlParams {
40
    #[serde(default, deserialize_with = "empty_string_as_none")]
41
    pub collapsed_depth: Option<String>,
42
    #[serde(default, deserialize_with = "empty_string_as_none")]
43
    pub commodity_columns: Option<String>,
44
}
45

            
46
impl TableControlParams {
47
    #[must_use]
48
    pub fn commodity_columns_enabled(&self) -> bool {
49
        self.commodity_columns
50
            .as_deref()
51
            .map(str::trim)
52
            .is_some_and(|v| v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true"))
53
    }
54
}
55

            
56
/// Chart-specific query parameters shared across report chart handlers.
57
/// `chart_kind` maps to `plotting::ChartKind`; `chart_series` is a
58
/// report-specific toggle (e.g. Activity: show Net or not) parsed in
59
/// each handler. `renderer` picks between server-side SVG (default)
60
/// and client-side canvas.
61
#[derive(Default, Deserialize)]
62
pub struct ChartParams {
63
    #[serde(default, deserialize_with = "empty_string_as_none")]
64
    pub chart_kind: Option<String>,
65
    #[serde(default, deserialize_with = "empty_string_as_none")]
66
    pub chart_series: Option<String>,
67
    #[serde(default, deserialize_with = "empty_string_as_none")]
68
    pub renderer: Option<String>,
69
}
70

            
71
impl ChartParams {
72
    /// Parse `chart_kind` (`bar`, `line`, `stacked`) into a
73
    /// `plotting::ChartKind`. Unknown / missing values fall back to
74
    /// `Bar` so a stale URL still renders something.
75
    #[must_use]
76
    pub fn chart_kind_or_default(&self) -> plotting::ChartKind {
77
        match self.chart_kind.as_deref().map(str::to_ascii_lowercase) {
78
            Some(ref s) if s == "line" => plotting::ChartKind::Line,
79
            Some(ref s) if s == "stacked" || s == "stackedbar" => plotting::ChartKind::StackedBar,
80
            _ => plotting::ChartKind::Bar,
81
        }
82
    }
83

            
84
    /// Normalise the chart kind to its canonical lowercase name so
85
    /// the template can round-trip it into URLs and `<select>`
86
    /// values.
87
    #[must_use]
88
    pub fn chart_kind_str(&self) -> String {
89
        match self.chart_kind_or_default() {
90
            plotting::ChartKind::Bar => "bar".to_string(),
91
            plotting::ChartKind::StackedBar => "stacked".to_string(),
92
            plotting::ChartKind::Line => "line".to_string(),
93
        }
94
    }
95

            
96
    /// Canonicalise the renderer choice. Accepts `svg` (default) and
97
    /// `canvas`; anything else normalises to `svg`.
98
    #[must_use]
99
    pub fn renderer_str(&self) -> String {
100
        match self.renderer.as_deref().map(str::to_ascii_lowercase) {
101
            Some(ref s) if s == "canvas" => "canvas".to_string(),
102
            _ => "svg".to_string(),
103
        }
104
    }
105
}
106

            
107
/// Minimal URL-query encoder for the (key, value) pairs the chart
108
/// endpoints take. Avoids pulling in `serde_urlencoded` just to
109
/// stringify a handful of params — keeps the handler simple.
110
#[must_use]
111
pub fn encode_query(pairs: &[(&str, &str)]) -> String {
112
    let mut out = String::new();
113
    for (k, v) in pairs {
114
        if v.is_empty() {
115
            continue;
116
        }
117
        if !out.is_empty() {
118
            out.push('&');
119
        }
120
        out.push_str(&percent_encode(k));
121
        out.push('=');
122
        out.push_str(&percent_encode(v));
123
    }
124
    if out.is_empty() {
125
        out
126
    } else {
127
        format!("?{out}")
128
    }
129
}
130

            
131
fn percent_encode(s: &str) -> String {
132
    let mut out = String::with_capacity(s.len());
133
    for b in s.bytes() {
134
        let keep = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
135
        if keep {
136
            out.push(b as char);
137
        } else {
138
            out.push_str(&format!("%{b:02X}"));
139
        }
140
    }
141
    out
142
}
143

            
144
pub async fn load_commodities(user_id: Uuid) -> Vec<CommodityOption> {
145
    let Ok(Some(CmdResult::TaggedEntities { entities, .. })) =
146
        ListCommodities::new().user_id(user_id).run().await
147
    else {
148
        return Vec::new();
149
    };
150

            
151
    let mut commodities = Vec::new();
152
    for (entity, tags) in entities {
153
        if let FinanceEntity::Commodity(commodity) = entity
154
            && let (FinanceEntity::Tag(s), FinanceEntity::Tag(n)) = (&tags["symbol"], &tags["name"])
155
        {
156
            commodities.push(CommodityOption {
157
                id: commodity.id,
158
                symbol: s.tag_value.clone(),
159
                name: n.tag_value.clone(),
160
            });
161
        }
162
    }
163
    commodities
164
}
165

            
166
/// Parse a `YYYY-MM-DD` string into UTC. `end_of_day` controls whether the
167
/// resulting instant sits at 00:00 (range start) or 23:59:59 (range end /
168
/// as-of cutoff).
169
#[must_use]
170
pub fn parse_date_bound(raw: &str, end_of_day: bool) -> Option<DateTime<Utc>> {
171
    let date = NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?;
172
    let time = if end_of_day {
173
        date.and_hms_opt(23, 59, 59)
174
    } else {
175
        date.and_hms_opt(0, 0, 0)
176
    }?;
177
    Some(time.and_utc())
178
}
179

            
180
#[must_use]
181
pub fn wants_json(headers: &HeaderMap) -> bool {
182
    headers
183
        .get("accept")
184
        .and_then(|v| v.to_str().ok())
185
        .is_some_and(|v| v.contains("application/json"))
186
}
187

            
188
/// Sum top-level rows' amounts per commodity. Used by Balance to build its
189
/// grand-total card(s).
190
#[must_use]
191
pub fn sum_top_level_amounts(rows: &[view::ReportRowView]) -> Vec<AmountView> {
192
    let mut out: Vec<AmountView> = Vec::new();
193
    for row in rows.iter().filter(|r| r.depth == 0) {
194
        for a in &row.amounts {
195
            match out
196
                .iter_mut()
197
                .find(|x| x.commodity_symbol == a.commodity_symbol)
198
            {
199
                Some(existing) => existing.amount += a.amount,
200
                None => out.push(AmountView {
201
                    commodity_symbol: a.commodity_symbol.clone(),
202
                    amount: a.amount,
203
                }),
204
            }
205
        }
206
    }
207
    out
208
}
209

            
210
/// Collect the set of commodity symbols present across all rows, in the
211
/// order the row-data first mentions them. Drives the "Commodities as
212
/// columns" pivot: a stable column ordering without depending on the
213
/// user's commodity list.
214
#[must_use]
215
pub fn commodity_symbols_in_rows(rows: &[view::ReportRowView]) -> Vec<String> {
216
    let mut seen = Vec::new();
217
    for row in rows {
218
        for a in &row.amounts {
219
            if !seen.iter().any(|s: &String| s == &a.commodity_symbol) {
220
                seen.push(a.commodity_symbol.clone());
221
            }
222
        }
223
    }
224
    seen
225
}
226

            
227
/// Look up a row's amount for the given commodity symbol. Called from the
228
/// commodity-columns branch of the tree-table template.
229
#[must_use]
230
pub fn row_amount_by_symbol(row: &view::ReportRowView, symbol: &str) -> Option<Rational64> {
231
    row.amounts
232
        .iter()
233
        .find(|a| a.commodity_symbol == symbol)
234
        .map(|a| a.amount)
235
}
236

            
237
/// Parse the shared `sort_order` query param. Balance and Breakdown
238
/// accept the same names; this keeps the URLs consistent between
239
/// them. Unknown values fall back to `AmountDesc` so a stale URL
240
/// still renders.
241
#[must_use]
242
pub fn parse_sort_order_shared(raw: Option<&str>) -> SharedSort {
243
    match raw.map(str::to_ascii_lowercase).as_deref() {
244
        Some("amount_asc") => SharedSort::AmountAsc,
245
        Some("name_asc") => SharedSort::NameAsc,
246
        Some("name_desc") => SharedSort::NameDesc,
247
        _ => SharedSort::AmountDesc,
248
    }
249
}
250

            
251
#[derive(Debug, Clone, Copy, Default)]
252
pub enum SharedSort {
253
    #[default]
254
    AmountDesc,
255
    AmountAsc,
256
    NameAsc,
257
    NameDesc,
258
}
259

            
260
impl SharedSort {
261
    #[must_use]
262
    pub fn to_str(self) -> &'static str {
263
        match self {
264
            Self::AmountDesc => "amount_desc",
265
            Self::AmountAsc => "amount_asc",
266
            Self::NameAsc => "name_asc",
267
            Self::NameDesc => "name_desc",
268
        }
269
    }
270

            
271
    /// Map to the plotting crate's balance-chart sort enum.
272
    #[must_use]
273
    pub fn into_plotting_balance(self) -> plotting::adapters::SortOrder {
274
        match self {
275
            Self::AmountDesc => plotting::adapters::SortOrder::MagnitudeDesc,
276
            Self::AmountAsc => plotting::adapters::SortOrder::MagnitudeAsc,
277
            Self::NameAsc => plotting::adapters::SortOrder::NameAsc,
278
            Self::NameDesc => plotting::adapters::SortOrder::NameDesc,
279
        }
280
    }
281
}
282

            
283
8
fn abs_rational(r: Rational64) -> Rational64 {
284
8
    if r < Rational64::new(0, 1) { -r } else { r }
285
8
}
286

            
287
/// Sort top-level rows in place according to `order`. Children ride
288
/// with their parent — they're adjacent in the flattened view and we
289
/// preserve that adjacency by sorting on a key derived from each
290
/// top-level row alone.
291
4
pub fn sort_top_level_rows(rows: &mut Vec<view::ReportRowView>, order: SharedSort) {
292
    // Group rows by their top-level ancestor. Flat projection is DFS
293
    // order: every depth-0 row is followed by its subtree before the
294
    // next depth-0 row, so a single pass builds the groups.
295
4
    let mut groups: Vec<Vec<view::ReportRowView>> = Vec::new();
296
16
    for row in std::mem::take(rows) {
297
16
        if row.depth == 0 || groups.is_empty() {
298
12
            groups.push(vec![row]);
299
12
        } else if let Some(last) = groups.last_mut() {
300
4
            last.push(row);
301
4
        }
302
    }
303

            
304
10
    groups.sort_by(|a, b| {
305
10
        let (a_root, b_root) = (&a[0], &b[0]);
306
10
        match order {
307
            SharedSort::AmountDesc => {
308
4
                let ka = a_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
309
4
                let kb = b_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
310
4
                kb.cmp(&ka)
311
            }
312
            SharedSort::AmountAsc => {
313
                let ka = a_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
314
                let kb = b_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
315
                ka.cmp(&kb)
316
            }
317
6
            SharedSort::NameAsc => a_root.account_name.cmp(&b_root.account_name),
318
            SharedSort::NameDesc => b_root.account_name.cmp(&a_root.account_name),
319
        }
320
10
    });
321

            
322
12
    for group in groups {
323
12
        rows.extend(group);
324
12
    }
325
4
}
326

            
327
#[cfg(test)]
328
mod sort_tests {
329
    use super::*;
330
    use num_rational::Rational64;
331
    use view::{AmountView, ReportRowView};
332

            
333
16
    fn mk_row(name: &str, depth: usize, amount: i64, parent: Option<Uuid>) -> ReportRowView {
334
16
        let id = Uuid::new_v4();
335
16
        ReportRowView {
336
16
            account_id: id,
337
16
            parent_id: parent,
338
16
            account_name: name.to_string(),
339
16
            depth,
340
16
            has_children: false,
341
16
            amounts: vec![AmountView {
342
16
                commodity_symbol: "USD".to_string(),
343
16
                amount: Rational64::new(amount, 1),
344
16
            }],
345
16
        }
346
16
    }
347

            
348
    #[test]
349
2
    fn sort_keeps_children_with_parents() {
350
2
        let parent_b = mk_row("Bank", 0, 500, None);
351
2
        let parent_b_id = parent_b.account_id;
352
2
        let parent_a = mk_row("Assets", 0, 1000, None);
353
2
        let parent_c = mk_row("Cash", 0, 200, None);
354
2
        let mut rows = vec![
355
2
            parent_b,
356
2
            mk_row("Checking", 1, 300, Some(parent_b_id)),
357
2
            mk_row("Savings", 1, 200, Some(parent_b_id)),
358
2
            parent_a,
359
2
            parent_c,
360
        ];
361

            
362
2
        sort_top_level_rows(&mut rows, SharedSort::AmountDesc);
363
10
        let names: Vec<&str> = rows.iter().map(|r| r.account_name.as_str()).collect();
364
2
        assert_eq!(
365
            names,
366
2
            vec!["Assets", "Bank", "Checking", "Savings", "Cash"],
367
            "Assets first (1000), Bank + children next (500), Cash last (200)"
368
        );
369
2
    }
370

            
371
    #[test]
372
2
    fn sort_by_name_ascending() {
373
2
        let mut rows = vec![
374
2
            mk_row("Zulu", 0, 1, None),
375
2
            mk_row("Alpha", 0, 2, None),
376
2
            mk_row("Mike", 0, 3, None),
377
        ];
378
2
        sort_top_level_rows(&mut rows, SharedSort::NameAsc);
379
6
        let names: Vec<&str> = rows.iter().map(|r| r.account_name.as_str()).collect();
380
2
        assert_eq!(names, vec!["Alpha", "Mike", "Zulu"]);
381
2
    }
382
}
383

            
384
/// Breakdown equivalent of `row_amount_by_symbol`. Category Breakdown uses
385
/// a flat row type (`BreakdownRowView`) rather than the tree `ReportRowView`.
386
#[must_use]
387
pub fn breakdown_row_amount_by_symbol(
388
    row: &view::BreakdownRowView,
389
    symbol: &str,
390
) -> Option<Rational64> {
391
    row.amounts
392
        .iter()
393
        .find(|a| a.commodity_symbol == symbol)
394
        .map(|a| a.amount)
395
}
396

            
397
pub fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
398
where
399
    D: Deserializer<'de>,
400
{
401
    let opt = Option::<String>::deserialize(deserializer)?;
402
    Ok(opt.filter(|s| !s.is_empty()))
403
}
404

            
405
#[must_use]
406
pub fn today_string() -> String {
407
    Utc::now().date_naive().format("%Y-%m-%d").to_string()
408
}
409

            
410
#[must_use]
411
pub fn month_start_string() -> String {
412
    let now = Utc::now().date_naive();
413
    NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
414
        .unwrap_or(now)
415
        .format("%Y-%m-%d")
416
        .to_string()
417
}
418

            
419
#[derive(Deserialize)]
420
#[serde(tag = "type")]
421
enum FilterItem {
422
    #[serde(rename = "tag")]
423
    Tag {
424
        entities: Vec<String>,
425
        name: String,
426
        values: Vec<String>,
427
    },
428
    #[serde(rename = "account")]
429
    Account {
430
        account_id: String,
431
        #[serde(default, rename = "display_name")]
432
        _display_name: String,
433
        include_subtree: bool,
434
    },
435
    #[serde(rename = "group")]
436
    Group {
437
        logic: String,
438
        items: Vec<FilterItem>,
439
    },
440
}
441

            
442
#[derive(Deserialize)]
443
struct FilterGroup {
444
    logic: String,
445
    items: Vec<FilterItem>,
446
}
447

            
448
14
fn build_tag_entity_filter(entity: FilterEntity, name: &str, values: &[String]) -> ReportFilter {
449
14
    match values.len() {
450
14
        1 => ReportFilter::Tag {
451
14
            entity,
452
14
            name: name.to_string(),
453
14
            value: values[0].clone(),
454
14
        },
455
        _ => ReportFilter::TagIn {
456
            entity,
457
            name: name.to_string(),
458
            values: values.to_vec(),
459
        },
460
    }
461
14
}
462

            
463
16
fn build_filter_item(item: &FilterItem) -> Option<ReportFilter> {
464
16
    match item {
465
        FilterItem::Tag {
466
14
            entities,
467
14
            name,
468
14
            values,
469
        } => {
470
14
            if name.trim().is_empty() || values.is_empty() {
471
                return None;
472
14
            }
473
14
            let filters: Vec<ReportFilter> = entities
474
14
                .iter()
475
14
                .filter_map(|e| {
476
14
                    let entity = match e.as_str() {
477
14
                        "account" => FilterEntity::Account,
478
14
                        "transaction" => FilterEntity::Transaction,
479
                        "split" => FilterEntity::Split,
480
                        _ => return None,
481
                    };
482
14
                    Some(build_tag_entity_filter(entity, name, values))
483
14
                })
484
14
                .collect();
485
14
            match filters.len() {
486
                0 => None,
487
14
                1 => filters.into_iter().next(),
488
                _ => Some(ReportFilter::Or(filters)),
489
            }
490
        }
491
        FilterItem::Account {
492
            account_id,
493
            include_subtree,
494
            ..
495
        } => {
496
            let id = account_id.parse::<Uuid>().ok()?;
497
            if *include_subtree {
498
                Some(ReportFilter::AccountSubtree(id))
499
            } else {
500
                Some(ReportFilter::AccountEq(id))
501
            }
502
        }
503
2
        FilterItem::Group { logic, items } => {
504
2
            let filters: Vec<ReportFilter> = items.iter().filter_map(build_filter_item).collect();
505
2
            combine_group(logic, filters)
506
        }
507
    }
508
16
}
509

            
510
/// Combine a group's built child filters by the group's declared logic.
511
/// `not` requires exactly one child; anything else is ignored (returns
512
/// `None`) so the rest of the report still runs. Users who want
513
/// `Not(Or(...))` or `Not(And(...))` must nest the subgroup explicitly.
514
12
fn combine_group(logic: &str, filters: Vec<ReportFilter>) -> Option<ReportFilter> {
515
12
    match logic {
516
12
        "not" => match filters.len() {
517
4
            1 => filters
518
4
                .into_iter()
519
4
                .next()
520
4
                .map(|f| ReportFilter::Not(Box::new(f))),
521
4
            _ => None,
522
        },
523
4
        "or" => match filters.len() {
524
            0 => None,
525
            1 => filters.into_iter().next(),
526
2
            _ => Some(ReportFilter::Or(filters)),
527
        },
528
2
        _ => match filters.len() {
529
            0 => None,
530
            1 => filters.into_iter().next(),
531
2
            _ => Some(ReportFilter::And(filters)),
532
        },
533
    }
534
12
}
535

            
536
10
fn build_filter_from_group(group: &FilterGroup) -> Option<ReportFilter> {
537
10
    let filters: Vec<ReportFilter> = group.items.iter().filter_map(build_filter_item).collect();
538
10
    combine_group(&group.logic, filters)
539
10
}
540

            
541
#[must_use]
542
pub fn build_report_filter(
543
    tag_filters: Option<&str>,
544
    tag_filter_mode: Option<&str>,
545
) -> Option<ReportFilter> {
546
    let raw = tag_filters.filter(|s| !s.is_empty())?;
547

            
548
    if tag_filter_mode == Some("script") {
549
        return build_filter_from_sexpr(raw);
550
    }
551

            
552
    let group: FilterGroup = serde_json::from_str(raw).ok()?;
553
    build_filter_from_group(&group)
554
}
555

            
556
#[cfg(feature = "scripting")]
557
fn build_filter_from_sexpr(raw: &str) -> Option<ReportFilter> {
558
    ReportFilter::from_sexpr(raw).ok()
559
}
560

            
561
#[cfg(not(feature = "scripting"))]
562
fn build_filter_from_sexpr(_raw: &str) -> Option<ReportFilter> {
563
    None
564
}
565

            
566
#[cfg(test)]
567
mod tests {
568
    use super::*;
569

            
570
14
    fn tag_item(name: &str, value: &str) -> FilterItem {
571
14
        FilterItem::Tag {
572
14
            entities: vec!["transaction".to_owned()],
573
14
            name: name.to_owned(),
574
14
            values: vec![value.to_owned()],
575
14
        }
576
14
    }
577

            
578
    #[test]
579
2
    fn not_group_with_single_child_becomes_not() {
580
2
        let group = FilterGroup {
581
2
            logic: "not".to_owned(),
582
2
            items: vec![tag_item("category", "food")],
583
2
        };
584
2
        let result = build_filter_from_group(&group).expect("filter built");
585
2
        match result {
586
2
            ReportFilter::Not(inner) => match *inner {
587
2
                ReportFilter::Tag { name, value, .. } => {
588
2
                    assert_eq!(name, "category");
589
2
                    assert_eq!(value, "food");
590
                }
591
                other => panic!("expected inner Tag, got {other:?}"),
592
            },
593
            other => panic!("expected Not(Tag), got {other:?}"),
594
        }
595
2
    }
596

            
597
    #[test]
598
2
    fn not_group_with_zero_children_is_dropped() {
599
2
        let group = FilterGroup {
600
2
            logic: "not".to_owned(),
601
2
            items: vec![],
602
2
        };
603
2
        assert!(build_filter_from_group(&group).is_none());
604
2
    }
605

            
606
    #[test]
607
2
    fn not_group_with_multiple_children_is_dropped() {
608
2
        let group = FilterGroup {
609
2
            logic: "not".to_owned(),
610
2
            items: vec![tag_item("category", "food"), tag_item("category", "rent")],
611
2
        };
612
2
        assert!(
613
2
            build_filter_from_group(&group).is_none(),
614
            "multi-child NOT must be invalid; users nest explicitly"
615
        );
616
2
    }
617

            
618
    #[test]
619
2
    fn explicit_not_of_or_via_nesting() {
620
        // Users who want Not(Or(food, rent)) nest an OR subgroup inside a NOT.
621
2
        let group = FilterGroup {
622
2
            logic: "not".to_owned(),
623
2
            items: vec![FilterItem::Group {
624
2
                logic: "or".to_owned(),
625
2
                items: vec![tag_item("category", "food"), tag_item("category", "rent")],
626
2
            }],
627
2
        };
628
2
        let result = build_filter_from_group(&group).expect("filter built");
629
2
        let ReportFilter::Not(inner) = result else {
630
            panic!("expected Not");
631
        };
632
2
        let ReportFilter::Or(children) = *inner else {
633
            panic!("expected Not(Or(..))");
634
        };
635
2
        assert_eq!(children.len(), 2);
636
2
    }
637

            
638
    #[test]
639
2
    fn and_group_unchanged() {
640
2
        let group = FilterGroup {
641
2
            logic: "and".to_owned(),
642
2
            items: vec![tag_item("category", "food"), tag_item("project", "roof")],
643
2
        };
644
2
        let result = build_filter_from_group(&group).expect("filter built");
645
2
        assert!(matches!(result, ReportFilter::And(v) if v.len() == 2));
646
2
    }
647
}