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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
449
/// A refusal the caller can act on (`CmdError::Args`) is a 400; anything else
450
/// is ours. Logged because the status travels without a body: these handlers
451
/// return `StatusCode`, and htmx does not swap a 4xx, so the reason would
452
/// otherwise be visible to nobody.
453
pub(super) fn report_status(err: server::command::CmdError) -> StatusCode {
454
    match err {
455
        server::command::CmdError::Args(msg) => {
456
            log::warn!("report refused: {msg}");
457
            StatusCode::BAD_REQUEST
458
        }
459
        _ => StatusCode::INTERNAL_SERVER_ERROR,
460
    }
461
}
462

            
463
28
fn build_tag_entity_filter(entity: FilterEntity, name: &str, values: &[String]) -> ReportFilter {
464
28
    match values.len() {
465
28
        1 => ReportFilter::Tag {
466
28
            entity,
467
28
            name: name.to_string(),
468
28
            value: values[0].clone(),
469
28
        },
470
        _ => ReportFilter::TagIn {
471
            entity,
472
            name: name.to_string(),
473
            values: values.to_vec(),
474
        },
475
    }
476
28
}
477

            
478
/// `Ok(None)` is an item the user has not filled in yet — a blank row, an empty
479
/// nested group. It contributes no condition and is skipped.
480
///
481
/// `Err` is an item that WAS filled in and is malformed. It must never be
482
/// silently dropped: dropping one child of an `and` widens the filter and
483
/// answers a narrower question with a broader report, under a 200.
484
42
fn build_filter_item(item: &FilterItem) -> Result<Option<ReportFilter>, ()> {
485
42
    match item {
486
        FilterItem::Tag {
487
32
            entities,
488
32
            name,
489
32
            values,
490
        } => {
491
32
            if name.trim().is_empty() || values.is_empty() {
492
2
                return Ok(None);
493
30
            }
494
30
            let mut seen = Vec::new();
495
30
            let filters = entities
496
30
                .iter()
497
1026
                .filter(|e| {
498
1026
                    let fresh = !seen.contains(e);
499
1026
                    if fresh {
500
28
                        seen.push(*e);
501
998
                    }
502
1026
                    fresh
503
1026
                })
504
30
                .map(|e| match e.as_str() {
505
28
                    "account" => Ok(build_tag_entity_filter(FilterEntity::Account, name, values)),
506
26
                    "transaction" => Ok(build_tag_entity_filter(
507
26
                        FilterEntity::Transaction,
508
26
                        name,
509
26
                        values,
510
26
                    )),
511
                    "split" => Ok(build_tag_entity_filter(FilterEntity::Split, name, values)),
512
                    _ => Err(()),
513
28
                })
514
30
                .collect::<Result<Vec<ReportFilter>, ()>>()?;
515
30
            match filters.len() {
516
                // Name and values were filled in; with no entity ticked the
517
                // condition cannot be built, and dropping it widens the filter.
518
2
                0 => Err(()),
519
28
                1 => Ok(filters.into_iter().next()),
520
                _ => Ok(Some(ReportFilter::Or(filters))),
521
            }
522
        }
523
        FilterItem::Account {
524
6
            account_id,
525
6
            include_subtree,
526
            ..
527
        } => {
528
6
            if account_id.trim().is_empty() {
529
4
                return Ok(None);
530
2
            }
531
2
            let id = account_id.parse::<Uuid>().map_err(|_| ())?;
532
            Ok(Some(if *include_subtree {
533
                ReportFilter::AccountSubtree(id)
534
            } else {
535
                ReportFilter::AccountEq(id)
536
            }))
537
        }
538
4
        FilterItem::Group { logic, items } => group_filter(logic, items),
539
    }
540
42
}
541

            
542
/// Every filled-in item, in order. Propagates the first malformed one.
543
28
fn build_filter_items(items: &[FilterItem]) -> Result<Vec<ReportFilter>, ()> {
544
28
    let mut built = Vec::with_capacity(items.len());
545
36
    for item in items {
546
36
        if let Some(filter) = build_filter_item(item)? {
547
28
            built.push(filter);
548
28
        }
549
    }
550
24
    Ok(built)
551
28
}
552

            
553
/// Combine a group's built child filters by the group's declared logic.
554
/// `not` requires exactly one child. `None` refuses the report: an operator
555
/// the UI never emits must not quietly become `and`. Users who want
556
/// `Not(Or(...))` or `Not(And(...))` must nest the subgroup explicitly.
557
16
fn combine_group(logic: &str, filters: Vec<ReportFilter>) -> Option<ReportFilter> {
558
16
    match logic {
559
16
        "not" => match filters.len() {
560
4
            1 => filters
561
4
                .into_iter()
562
4
                .next()
563
4
                .map(|f| ReportFilter::Not(Box::new(f))),
564
4
            _ => None,
565
        },
566
8
        "and" | "or" => match filters.len() {
567
            0 => None,
568
4
            1 => filters.into_iter().next(),
569
4
            _ if logic == "or" => Some(ReportFilter::Or(filters)),
570
2
            _ => Some(ReportFilter::And(filters)),
571
        },
572
        _ => None,
573
    }
574
16
}
575

            
576
34
fn build_filter_from_group(group: &FilterGroup) -> Result<Option<ReportFilter>, ()> {
577
34
    group_filter(&group.logic, &group.items)
578
34
}
579

            
580
/// A group's operator is validated even when the group turns out to be empty:
581
/// returning early on `items` left `{"logic":"xor","items":[]}` accepted.
582
38
fn group_filter(logic: &str, items: &[FilterItem]) -> Result<Option<ReportFilter>, ()> {
583
38
    if !matches!(logic, "and" | "or" | "not") {
584
10
        return Err(());
585
28
    }
586
28
    let filters = build_filter_items(items)?;
587
24
    if filters.is_empty() {
588
8
        return Ok(None);
589
16
    }
590
16
    combine_group(logic, filters).map(Some).ok_or(())
591
38
}
592

            
593
/// `Ok(None)` means no filter was asked for. A filter that was asked for and
594
/// could not be parsed is an error, never `None`: falling back to "no filter"
595
/// answers a narrower question with a broader report — right status, wrong
596
/// numbers, no signal to the reader.
597
///
598
/// # Errors
599
/// `StatusCode::BAD_REQUEST` when `tag_filters` is present but unparsable.
600
16
pub fn build_report_filter(
601
16
    tag_filters: Option<&str>,
602
16
    tag_filter_mode: Option<&str>,
603
16
) -> Result<Option<ReportFilter>, StatusCode> {
604
16
    let Some(raw) = tag_filters.filter(|s| !s.is_empty()) else {
605
4
        return Ok(None);
606
    };
607

            
608
12
    if tag_filter_mode == Some("script") {
609
2
        return build_filter_from_sexpr(raw)
610
2
            .map(Some)
611
2
            .ok_or(StatusCode::BAD_REQUEST);
612
10
    }
613

            
614
10
    let group: FilterGroup = serde_json::from_str(raw).map_err(|_| StatusCode::BAD_REQUEST)?;
615
    // A filter panel the user opened and left empty means "no conditions", not a
616
    // malformed request — but its operator is still validated, so this cannot
617
    // return early: `group_filter` answers `Ok(None)` for an empty valid group.
618
8
    build_filter_from_group(&group).map_err(|()| StatusCode::BAD_REQUEST)
619
16
}
620

            
621
2
fn build_filter_from_sexpr(raw: &str) -> Option<ReportFilter> {
622
2
    ReportFilter::from_sexpr(raw).ok()
623
2
}
624

            
625
#[cfg(test)]
626
mod tests {
627
    use super::*;
628

            
629
    /// A malformed child must not be dropped from an otherwise valid parent:
630
    /// that widens the filter and answers a narrower question with a broader
631
    /// report, under a 200. Reachable from the UI, which paints a bad nested
632
    /// group red and submits it anyway.
633
    #[test]
634
2
    fn a_malformed_child_does_not_silently_widen_the_filter() {
635
2
        let bad_uuid = FilterItem::Account {
636
2
            account_id: "not-a-uuid".to_owned(),
637
2
            include_subtree: false,
638
2
            _display_name: String::new(),
639
2
        };
640
2
        let group = FilterGroup {
641
2
            logic: "and".to_owned(),
642
2
            items: vec![tag_item("category", "food"), bad_uuid],
643
2
        };
644
2
        assert!(
645
2
            build_filter_from_group(&group).is_err(),
646
            "a bad child must fail the request, not vanish from the AND"
647
        );
648

            
649
2
        let nested_bad = FilterGroup {
650
2
            logic: "and".to_owned(),
651
2
            items: vec![
652
2
                tag_item("category", "food"),
653
2
                FilterItem::Group {
654
2
                    logic: "not".to_owned(),
655
2
                    items: vec![tag_item("a", "1"), tag_item("b", "2")],
656
2
                },
657
2
            ],
658
2
        };
659
2
        assert!(
660
2
            build_filter_from_group(&nested_bad).is_err(),
661
            "a malformed NESTED group must fail too — same input, same answer at any depth"
662
        );
663
2
    }
664

            
665
    /// A row the user has not filled in yet contributes nothing and is skipped.
666
    #[test]
667
2
    fn an_unfilled_row_is_skipped_not_refused() {
668
2
        let group = FilterGroup {
669
2
            logic: "and".to_owned(),
670
2
            items: vec![tag_item("category", "food"), tag_item("", "")],
671
2
        };
672
2
        assert!(
673
2
            matches!(build_filter_from_group(&group), Ok(Some(_))),
674
            "a blank row is normal UI state"
675
        );
676
2
    }
677

            
678
    /// An empty filter panel is well-formed JSON meaning "no conditions". It
679
    /// must stay `Ok(None)` — rejecting it answers a legitimate request with a
680
    /// 400 and the report panel simply stops updating.
681
    #[test]
682
2
    fn an_empty_filter_group_is_no_filter_not_an_error() {
683
4
        for raw in [
684
2
            r#"{"logic":"and","items":[]}"#,
685
2
            r#"{"logic":"or","items":[]}"#,
686
2
        ] {
687
4
            assert!(
688
4
                matches!(build_report_filter(Some(raw), None), Ok(None)),
689
                "an empty group must mean no filter: {raw}"
690
            );
691
        }
692
2
    }
693

            
694
    /// A filter that was asked for and cannot be parsed must NOT silently
695
    /// become "no filter" — that answers a narrower question with a broader
696
    /// report under a 200.
697
    #[test]
698
2
    fn an_unparsable_filter_is_refused() {
699
2
        assert!(build_report_filter(Some("{not json"), None).is_err());
700
2
        assert!(build_report_filter(Some("(and"), Some("script")).is_err());
701
2
    }
702

            
703
    /// Absent or empty means no filter was requested at all.
704
    #[test]
705
2
    fn no_filter_requested_is_ok_none() {
706
2
        assert!(matches!(build_report_filter(None, None), Ok(None)));
707
2
        assert!(matches!(build_report_filter(Some(""), None), Ok(None)));
708
2
    }
709

            
710
40
    fn tag_item(name: &str, value: &str) -> FilterItem {
711
40
        FilterItem::Tag {
712
40
            entities: vec!["transaction".to_owned()],
713
40
            name: name.to_owned(),
714
40
            values: vec![value.to_owned()],
715
40
        }
716
40
    }
717

            
718
    #[test]
719
2
    fn not_group_with_single_child_becomes_not() {
720
2
        let group = FilterGroup {
721
2
            logic: "not".to_owned(),
722
2
            items: vec![tag_item("category", "food")],
723
2
        };
724
2
        let result = build_filter_from_group(&group)
725
2
            .expect("filter built")
726
2
            .expect("a condition");
727
2
        match result {
728
2
            ReportFilter::Not(inner) => match *inner {
729
2
                ReportFilter::Tag { name, value, .. } => {
730
2
                    assert_eq!(name, "category");
731
2
                    assert_eq!(value, "food");
732
                }
733
                other => panic!("expected inner Tag, got {other:?}"),
734
            },
735
            other => panic!("expected Not(Tag), got {other:?}"),
736
        }
737
2
    }
738

            
739
    #[test]
740
2
    fn not_group_with_zero_children_contributes_nothing() {
741
2
        let group = FilterGroup {
742
2
            logic: "not".to_owned(),
743
2
            items: vec![],
744
2
        };
745
2
        assert!(
746
2
            matches!(build_filter_from_group(&group), Ok(None)),
747
            "an unfilled group is no condition, not a malformed request"
748
        );
749
2
    }
750

            
751
    #[test]
752
2
    fn not_group_with_multiple_children_is_refused() {
753
2
        let group = FilterGroup {
754
2
            logic: "not".to_owned(),
755
2
            items: vec![tag_item("category", "food"), tag_item("category", "rent")],
756
2
        };
757
2
        assert!(
758
2
            build_filter_from_group(&group).is_err(),
759
            "multi-child NOT is malformed; dropping it would widen the filter"
760
        );
761
2
    }
762

            
763
    #[test]
764
2
    fn explicit_not_of_or_via_nesting() {
765
        // Users who want Not(Or(food, rent)) nest an OR subgroup inside a NOT.
766
2
        let group = FilterGroup {
767
2
            logic: "not".to_owned(),
768
2
            items: vec![FilterItem::Group {
769
2
                logic: "or".to_owned(),
770
2
                items: vec![tag_item("category", "food"), tag_item("category", "rent")],
771
2
            }],
772
2
        };
773
2
        let result = build_filter_from_group(&group)
774
2
            .expect("filter built")
775
2
            .expect("a condition");
776
2
        let ReportFilter::Not(inner) = result else {
777
            panic!("expected Not");
778
        };
779
2
        let ReportFilter::Or(children) = *inner else {
780
            panic!("expected Not(Or(..))");
781
        };
782
2
        assert_eq!(children.len(), 2);
783
2
    }
784

            
785
    #[test]
786
2
    fn and_group_unchanged() {
787
2
        let group = FilterGroup {
788
2
            logic: "and".to_owned(),
789
2
            items: vec![tag_item("category", "food"), tag_item("project", "roof")],
790
2
        };
791
2
        let result = build_filter_from_group(&group)
792
2
            .expect("filter built")
793
2
            .expect("a condition");
794
2
        assert!(matches!(result, ReportFilter::And(v) if v.len() == 2));
795
2
    }
796

            
797
    /// A row the user filled in but that cannot be turned into a condition is
798
    /// malformed, not blank: dropping it widens the filter under a 200.
799
    #[test]
800
2
    fn a_tag_row_with_no_entity_ticked_is_refused() {
801
2
        let item = FilterItem::Tag {
802
2
            entities: vec![],
803
2
            name: "category".to_owned(),
804
2
            values: vec!["food".to_owned()],
805
2
        };
806
2
        assert!(build_filter_item(&item).is_err());
807
2
    }
808

            
809
    /// The other direction: a row nobody touched is skipped, not refused.
810
    #[test]
811
2
    fn a_blank_account_row_is_skipped_not_refused() {
812
2
        let blank = FilterItem::Account {
813
2
            account_id: "  ".to_owned(),
814
2
            include_subtree: false,
815
2
            _display_name: String::new(),
816
2
        };
817
2
        assert!(matches!(build_filter_item(&blank), Ok(None)));
818
2
        let group = FilterGroup {
819
2
            logic: "and".to_owned(),
820
2
            items: vec![tag_item("category", "food"), blank],
821
2
        };
822
2
        assert!(matches!(
823
2
            build_filter_from_group(&group),
824
            Ok(Some(ReportFilter::Tag { .. }))
825
        ));
826
2
    }
827

            
828
    /// An operator the UI never emits must not quietly become AND.
829
    #[test]
830
2
    fn an_unknown_group_operator_is_refused() {
831
6
        for logic in ["xor", "", "AND"] {
832
6
            let group = FilterGroup {
833
6
                logic: logic.to_owned(),
834
6
                items: vec![tag_item("category", "food"), tag_item("payee", "shop")],
835
6
            };
836
6
            assert!(
837
6
                build_filter_from_group(&group).is_err(),
838
                "{logic} must not be accepted as AND"
839
            );
840
        }
841
2
    }
842

            
843
    /// The operator was validated only after the items were built, so an
844
    /// unknown one slipped through whenever the group happened to be empty.
845
    #[test]
846
2
    fn an_unknown_group_operator_is_refused_even_when_empty() {
847
2
        let group = FilterGroup {
848
2
            logic: "xor".to_owned(),
849
2
            items: vec![],
850
2
        };
851
2
        assert!(build_filter_from_group(&group).is_err());
852
2
    }
853

            
854
    /// Duplicate entities each cloned the whole value list, turning a small
855
    /// payload into an arbitrarily large one before any cap was consulted.
856
    #[test]
857
2
    fn repeated_entities_build_one_condition_each() {
858
2
        let item = FilterItem::Tag {
859
2
            entities: vec!["account".to_owned(); 500],
860
2
            name: "category".to_owned(),
861
2
            values: vec!["food".to_owned()],
862
2
        };
863
2
        assert!(matches!(
864
2
            build_filter_item(&item),
865
            Ok(Some(ReportFilter::Tag { .. }))
866
        ));
867
2
    }
868

            
869
    /// The entry point short-circuited on empty items, so an unknown operator
870
    /// was never validated — the report ran unfiltered under a 200.
871
    #[test]
872
2
    fn an_unknown_operator_is_refused_through_the_entry_point() {
873
2
        assert_eq!(
874
2
            build_report_filter(Some(r#"{"logic":"xor","items":[]}"#), None).err(),
875
            Some(StatusCode::BAD_REQUEST)
876
        );
877
        // A panel opened and left empty is still no filter, not an error.
878
2
        assert!(matches!(
879
2
            build_report_filter(Some(r#"{"logic":"and","items":[]}"#), None),
880
            Ok(None)
881
        ));
882
2
    }
883
}