Skip to main content

web/pages/report/
mod.rs

1use axum::http::HeaderMap;
2use axum::http::StatusCode;
3use chrono::{DateTime, Datelike, Utc};
4use num_rational::Rational64;
5use serde::{Deserialize, Deserializer};
6use server::command::{
7    CmdResult, FilterEntity, FinanceEntity, ReportFilter, commodity::ListCommodities,
8};
9use sqlx::types::{Uuid, chrono::NaiveDate};
10
11pub mod activity;
12pub mod balance;
13pub mod category_breakdown;
14
15use server::command::report::view;
16use view::AmountView;
17
18pub 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.
27pub 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)]
40pub 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
47impl 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)]
63pub 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
72impl 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]
112pub 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
132fn 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
145pub 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]
171pub fn parse_date_bound(raw: &str, end_of_day: bool) -> Option<DateTime<Utc>> {
172    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}
180
181#[must_use]
182pub 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]
192pub 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]
216pub 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]
231pub 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]
243pub 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)]
253pub enum SharedSort {
254    #[default]
255    AmountDesc,
256    AmountAsc,
257    NameAsc,
258    NameDesc,
259}
260
261impl 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
284fn abs_rational(r: Rational64) -> Rational64 {
285    if r < Rational64::new(0, 1) { -r } else { r }
286}
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.
292pub 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    let mut groups: Vec<Vec<view::ReportRowView>> = Vec::new();
297    for row in std::mem::take(rows) {
298        if row.depth == 0 || groups.is_empty() {
299            groups.push(vec![row]);
300        } else if let Some(last) = groups.last_mut() {
301            last.push(row);
302        }
303    }
304
305    groups.sort_by(|a, b| {
306        let (a_root, b_root) = (&a[0], &b[0]);
307        match order {
308            SharedSort::AmountDesc => {
309                let ka = a_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
310                let kb = b_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
311                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            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    });
322
323    for group in groups {
324        rows.extend(group);
325    }
326}
327
328#[cfg(test)]
329mod sort_tests {
330    use super::*;
331    use num_rational::Rational64;
332    use view::{AmountView, ReportRowView};
333
334    fn mk_row(name: &str, depth: usize, amount: i64, parent: Option<Uuid>) -> ReportRowView {
335        let id = Uuid::new_v4();
336        ReportRowView {
337            account_id: id,
338            parent_id: parent,
339            account_name: name.to_string(),
340            depth,
341            has_children: false,
342            amounts: vec![AmountView {
343                commodity_symbol: "USD".to_string(),
344                amount: Rational64::new(amount, 1),
345            }],
346        }
347    }
348
349    #[test]
350    fn sort_keeps_children_with_parents() {
351        let parent_b = mk_row("Bank", 0, 500, None);
352        let parent_b_id = parent_b.account_id;
353        let parent_a = mk_row("Assets", 0, 1000, None);
354        let parent_c = mk_row("Cash", 0, 200, None);
355        let mut rows = vec![
356            parent_b,
357            mk_row("Checking", 1, 300, Some(parent_b_id)),
358            mk_row("Savings", 1, 200, Some(parent_b_id)),
359            parent_a,
360            parent_c,
361        ];
362
363        sort_top_level_rows(&mut rows, SharedSort::AmountDesc);
364        let names: Vec<&str> = rows.iter().map(|r| r.account_name.as_str()).collect();
365        assert_eq!(
366            names,
367            vec!["Assets", "Bank", "Checking", "Savings", "Cash"],
368            "Assets first (1000), Bank + children next (500), Cash last (200)"
369        );
370    }
371
372    #[test]
373    fn sort_by_name_ascending() {
374        let mut rows = vec![
375            mk_row("Zulu", 0, 1, None),
376            mk_row("Alpha", 0, 2, None),
377            mk_row("Mike", 0, 3, None),
378        ];
379        sort_top_level_rows(&mut rows, SharedSort::NameAsc);
380        let names: Vec<&str> = rows.iter().map(|r| r.account_name.as_str()).collect();
381        assert_eq!(names, vec!["Alpha", "Mike", "Zulu"]);
382    }
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]
388pub 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
398pub fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
399where
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]
407pub fn today_string() -> String {
408    Utc::now().date_naive().format("%Y-%m-%d").to_string()
409}
410
411#[must_use]
412pub 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")]
422enum 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)]
444struct 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.
453pub(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
463fn build_tag_entity_filter(entity: FilterEntity, name: &str, values: &[String]) -> ReportFilter {
464    match values.len() {
465        1 => ReportFilter::Tag {
466            entity,
467            name: name.to_string(),
468            value: values[0].clone(),
469        },
470        _ => ReportFilter::TagIn {
471            entity,
472            name: name.to_string(),
473            values: values.to_vec(),
474        },
475    }
476}
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.
484fn build_filter_item(item: &FilterItem) -> Result<Option<ReportFilter>, ()> {
485    match item {
486        FilterItem::Tag {
487            entities,
488            name,
489            values,
490        } => {
491            if name.trim().is_empty() || values.is_empty() {
492                return Ok(None);
493            }
494            let mut seen = Vec::new();
495            let filters = entities
496                .iter()
497                .filter(|e| {
498                    let fresh = !seen.contains(e);
499                    if fresh {
500                        seen.push(*e);
501                    }
502                    fresh
503                })
504                .map(|e| match e.as_str() {
505                    "account" => Ok(build_tag_entity_filter(FilterEntity::Account, name, values)),
506                    "transaction" => Ok(build_tag_entity_filter(
507                        FilterEntity::Transaction,
508                        name,
509                        values,
510                    )),
511                    "split" => Ok(build_tag_entity_filter(FilterEntity::Split, name, values)),
512                    _ => Err(()),
513                })
514                .collect::<Result<Vec<ReportFilter>, ()>>()?;
515            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                0 => Err(()),
519                1 => Ok(filters.into_iter().next()),
520                _ => Ok(Some(ReportFilter::Or(filters))),
521            }
522        }
523        FilterItem::Account {
524            account_id,
525            include_subtree,
526            ..
527        } => {
528            if account_id.trim().is_empty() {
529                return Ok(None);
530            }
531            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        FilterItem::Group { logic, items } => group_filter(logic, items),
539    }
540}
541
542/// Every filled-in item, in order. Propagates the first malformed one.
543fn build_filter_items(items: &[FilterItem]) -> Result<Vec<ReportFilter>, ()> {
544    let mut built = Vec::with_capacity(items.len());
545    for item in items {
546        if let Some(filter) = build_filter_item(item)? {
547            built.push(filter);
548        }
549    }
550    Ok(built)
551}
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.
557fn combine_group(logic: &str, filters: Vec<ReportFilter>) -> Option<ReportFilter> {
558    match logic {
559        "not" => match filters.len() {
560            1 => filters
561                .into_iter()
562                .next()
563                .map(|f| ReportFilter::Not(Box::new(f))),
564            _ => None,
565        },
566        "and" | "or" => match filters.len() {
567            0 => None,
568            1 => filters.into_iter().next(),
569            _ if logic == "or" => Some(ReportFilter::Or(filters)),
570            _ => Some(ReportFilter::And(filters)),
571        },
572        _ => None,
573    }
574}
575
576fn build_filter_from_group(group: &FilterGroup) -> Result<Option<ReportFilter>, ()> {
577    group_filter(&group.logic, &group.items)
578}
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.
582fn group_filter(logic: &str, items: &[FilterItem]) -> Result<Option<ReportFilter>, ()> {
583    if !matches!(logic, "and" | "or" | "not") {
584        return Err(());
585    }
586    let filters = build_filter_items(items)?;
587    if filters.is_empty() {
588        return Ok(None);
589    }
590    combine_group(logic, filters).map(Some).ok_or(())
591}
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.
600pub fn build_report_filter(
601    tag_filters: Option<&str>,
602    tag_filter_mode: Option<&str>,
603) -> Result<Option<ReportFilter>, StatusCode> {
604    let Some(raw) = tag_filters.filter(|s| !s.is_empty()) else {
605        return Ok(None);
606    };
607
608    if tag_filter_mode == Some("script") {
609        return build_filter_from_sexpr(raw)
610            .map(Some)
611            .ok_or(StatusCode::BAD_REQUEST);
612    }
613
614    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    build_filter_from_group(&group).map_err(|()| StatusCode::BAD_REQUEST)
619}
620
621fn build_filter_from_sexpr(raw: &str) -> Option<ReportFilter> {
622    ReportFilter::from_sexpr(raw).ok()
623}
624
625#[cfg(test)]
626mod 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    fn a_malformed_child_does_not_silently_widen_the_filter() {
635        let bad_uuid = FilterItem::Account {
636            account_id: "not-a-uuid".to_owned(),
637            include_subtree: false,
638            _display_name: String::new(),
639        };
640        let group = FilterGroup {
641            logic: "and".to_owned(),
642            items: vec![tag_item("category", "food"), bad_uuid],
643        };
644        assert!(
645            build_filter_from_group(&group).is_err(),
646            "a bad child must fail the request, not vanish from the AND"
647        );
648
649        let nested_bad = FilterGroup {
650            logic: "and".to_owned(),
651            items: vec![
652                tag_item("category", "food"),
653                FilterItem::Group {
654                    logic: "not".to_owned(),
655                    items: vec![tag_item("a", "1"), tag_item("b", "2")],
656                },
657            ],
658        };
659        assert!(
660            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    }
664
665    /// A row the user has not filled in yet contributes nothing and is skipped.
666    #[test]
667    fn an_unfilled_row_is_skipped_not_refused() {
668        let group = FilterGroup {
669            logic: "and".to_owned(),
670            items: vec![tag_item("category", "food"), tag_item("", "")],
671        };
672        assert!(
673            matches!(build_filter_from_group(&group), Ok(Some(_))),
674            "a blank row is normal UI state"
675        );
676    }
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    fn an_empty_filter_group_is_no_filter_not_an_error() {
683        for raw in [
684            r#"{"logic":"and","items":[]}"#,
685            r#"{"logic":"or","items":[]}"#,
686        ] {
687            assert!(
688                matches!(build_report_filter(Some(raw), None), Ok(None)),
689                "an empty group must mean no filter: {raw}"
690            );
691        }
692    }
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    fn an_unparsable_filter_is_refused() {
699        assert!(build_report_filter(Some("{not json"), None).is_err());
700        assert!(build_report_filter(Some("(and"), Some("script")).is_err());
701    }
702
703    /// Absent or empty means no filter was requested at all.
704    #[test]
705    fn no_filter_requested_is_ok_none() {
706        assert!(matches!(build_report_filter(None, None), Ok(None)));
707        assert!(matches!(build_report_filter(Some(""), None), Ok(None)));
708    }
709
710    fn tag_item(name: &str, value: &str) -> FilterItem {
711        FilterItem::Tag {
712            entities: vec!["transaction".to_owned()],
713            name: name.to_owned(),
714            values: vec![value.to_owned()],
715        }
716    }
717
718    #[test]
719    fn not_group_with_single_child_becomes_not() {
720        let group = FilterGroup {
721            logic: "not".to_owned(),
722            items: vec![tag_item("category", "food")],
723        };
724        let result = build_filter_from_group(&group)
725            .expect("filter built")
726            .expect("a condition");
727        match result {
728            ReportFilter::Not(inner) => match *inner {
729                ReportFilter::Tag { name, value, .. } => {
730                    assert_eq!(name, "category");
731                    assert_eq!(value, "food");
732                }
733                other => panic!("expected inner Tag, got {other:?}"),
734            },
735            other => panic!("expected Not(Tag), got {other:?}"),
736        }
737    }
738
739    #[test]
740    fn not_group_with_zero_children_contributes_nothing() {
741        let group = FilterGroup {
742            logic: "not".to_owned(),
743            items: vec![],
744        };
745        assert!(
746            matches!(build_filter_from_group(&group), Ok(None)),
747            "an unfilled group is no condition, not a malformed request"
748        );
749    }
750
751    #[test]
752    fn not_group_with_multiple_children_is_refused() {
753        let group = FilterGroup {
754            logic: "not".to_owned(),
755            items: vec![tag_item("category", "food"), tag_item("category", "rent")],
756        };
757        assert!(
758            build_filter_from_group(&group).is_err(),
759            "multi-child NOT is malformed; dropping it would widen the filter"
760        );
761    }
762
763    #[test]
764    fn explicit_not_of_or_via_nesting() {
765        // Users who want Not(Or(food, rent)) nest an OR subgroup inside a NOT.
766        let group = FilterGroup {
767            logic: "not".to_owned(),
768            items: vec![FilterItem::Group {
769                logic: "or".to_owned(),
770                items: vec![tag_item("category", "food"), tag_item("category", "rent")],
771            }],
772        };
773        let result = build_filter_from_group(&group)
774            .expect("filter built")
775            .expect("a condition");
776        let ReportFilter::Not(inner) = result else {
777            panic!("expected Not");
778        };
779        let ReportFilter::Or(children) = *inner else {
780            panic!("expected Not(Or(..))");
781        };
782        assert_eq!(children.len(), 2);
783    }
784
785    #[test]
786    fn and_group_unchanged() {
787        let group = FilterGroup {
788            logic: "and".to_owned(),
789            items: vec![tag_item("category", "food"), tag_item("project", "roof")],
790        };
791        let result = build_filter_from_group(&group)
792            .expect("filter built")
793            .expect("a condition");
794        assert!(matches!(result, ReportFilter::And(v) if v.len() == 2));
795    }
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    fn a_tag_row_with_no_entity_ticked_is_refused() {
801        let item = FilterItem::Tag {
802            entities: vec![],
803            name: "category".to_owned(),
804            values: vec!["food".to_owned()],
805        };
806        assert!(build_filter_item(&item).is_err());
807    }
808
809    /// The other direction: a row nobody touched is skipped, not refused.
810    #[test]
811    fn a_blank_account_row_is_skipped_not_refused() {
812        let blank = FilterItem::Account {
813            account_id: "  ".to_owned(),
814            include_subtree: false,
815            _display_name: String::new(),
816        };
817        assert!(matches!(build_filter_item(&blank), Ok(None)));
818        let group = FilterGroup {
819            logic: "and".to_owned(),
820            items: vec![tag_item("category", "food"), blank],
821        };
822        assert!(matches!(
823            build_filter_from_group(&group),
824            Ok(Some(ReportFilter::Tag { .. }))
825        ));
826    }
827
828    /// An operator the UI never emits must not quietly become AND.
829    #[test]
830    fn an_unknown_group_operator_is_refused() {
831        for logic in ["xor", "", "AND"] {
832            let group = FilterGroup {
833                logic: logic.to_owned(),
834                items: vec![tag_item("category", "food"), tag_item("payee", "shop")],
835            };
836            assert!(
837                build_filter_from_group(&group).is_err(),
838                "{logic} must not be accepted as AND"
839            );
840        }
841    }
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    fn an_unknown_group_operator_is_refused_even_when_empty() {
847        let group = FilterGroup {
848            logic: "xor".to_owned(),
849            items: vec![],
850        };
851        assert!(build_filter_from_group(&group).is_err());
852    }
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    fn repeated_entities_build_one_condition_each() {
858        let item = FilterItem::Tag {
859            entities: vec!["account".to_owned(); 500],
860            name: "category".to_owned(),
861            values: vec!["food".to_owned()],
862        };
863        assert!(matches!(
864            build_filter_item(&item),
865            Ok(Some(ReportFilter::Tag { .. }))
866        ));
867    }
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    fn an_unknown_operator_is_refused_through_the_entry_point() {
873        assert_eq!(
874            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        assert!(matches!(
879            build_report_filter(Some(r#"{"logic":"and","items":[]}"#), None),
880            Ok(None)
881        ));
882    }
883}