Lines
78.76 %
Functions
58.44 %
Branches
100 %
//! Report-domain natives. Wraps `server::command::{BalanceReport, ActivityReport,
//! CategoryBreakdown}`.
use chrono::{DateTime, Utc};
use num_rational::Rational64;
use scripting::runtime::{alloc_string_ref, read_string_arg};
use server::command::report::{ActivityReport, BalanceReport, CategoryBreakdown};
use server::command::{
ActivityData, ActivityPeriod, BreakdownData, BreakdownPeriod, BreakdownRow, CmdResult,
ReportData, ReportNode,
};
use wasmtime::{ArrayRef, Caller, Linker, Rooted};
use crate::session::SessionData;
pub const REGISTERED_COMMANDS: &[&str] =
&["balance-report", "activity-report", "category-breakdown"];
pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
linker.func_wrap_async(
"nomi",
"report_balance_report",
|mut caller: Caller<'_, SessionData>,
()|
-> Box<
dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
> {
Box::new(async move {
let user_id = caller.data().ctx().user_id;
let payload = run_balance_report(user_id).await?;
Ok(Some(alloc_string_ref(&mut caller, payload.as_bytes())?))
})
},
)?;
"report_activity_report",
(from_arg, to_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
let from = read_string_arg(&mut caller, from_arg)?;
let to = read_string_arg(&mut caller, to_arg)?;
let payload = run_activity_report(user_id, from, to).await?;
"report_category_breakdown",
let payload = run_category_breakdown(user_id, from, to).await?;
Ok(())
}
fn parse_date_args(
name: &str,
from_arg: Option<String>,
to_arg: Option<String>,
) -> wasmtime::Result<(DateTime<Utc>, DateTime<Utc>)> {
let from_raw = from_arg
.filter(|s| !s.is_empty())
.ok_or_else(|| wasmtime::Error::msg(format!("{name}: missing or empty :date-from arg")))?;
let to_raw = to_arg
.ok_or_else(|| wasmtime::Error::msg(format!("{name}: missing or empty :date-to arg")))?;
let from = DateTime::parse_from_rfc3339(&from_raw)
.map(|d| d.with_timezone(&Utc))
.map_err(|err| {
wasmtime::Error::msg(format!("{name}: invalid :date-from '{from_raw}': {err}"))
})?;
let to = DateTime::parse_from_rfc3339(&to_raw)
wasmtime::Error::msg(format!("{name}: invalid :date-to '{to_raw}': {err}"))
Ok((from, to))
async fn run_activity_report(
user_id: uuid::Uuid,
) -> wasmtime::Result<String> {
let (from, to) = parse_date_args("activity-report", from_arg, to_arg)?;
match ActivityReport::new()
.user_id(user_id)
.date_from(from)
.date_to(to)
.run()
.await
{
Ok(Some(CmdResult::Activity(data))) => Ok(format_activity(&data)),
Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
"activity-report: expected Activity, got {other:?}"
))),
Ok(None) => Ok("(:activity-report :periods ())".to_string()),
Err(err) => Err(wasmtime::Error::msg(format!("activity-report: {err}"))),
async fn run_category_breakdown(
let (from, to) = parse_date_args("category-breakdown", from_arg, to_arg)?;
match CategoryBreakdown::new()
Ok(Some(CmdResult::Breakdown(data))) => Ok(format_breakdown(&data)),
"category-breakdown: expected Breakdown, got {other:?}"
Ok(None) => Ok("(:category-breakdown :periods ())".to_string()),
Err(err) => Err(wasmtime::Error::msg(format!("category-breakdown: {err}"))),
fn format_activity(data: &ActivityData) -> String {
let mut out = String::from("(:activity-report :meta ");
out.push_str(&format_meta(
data.meta.date_from.as_ref(),
data.meta.date_to.as_ref(),
data.meta.target_commodity_id.as_ref(),
));
out.push_str(" :periods (");
for (idx, period) in data.periods.iter().enumerate() {
if idx > 0 {
out.push(' ');
format_activity_period_into(&mut out, period);
out.push_str("))");
out
fn format_activity_period_into(out: &mut String, period: &ActivityPeriod) {
out.push_str("(:label ");
match period.label.as_deref() {
Some(label) => out.push_str("e_string(label)),
None => out.push_str("nil"),
out.push_str(" :groups (");
for (idx, group) in period.groups.iter().enumerate() {
out.push_str(&format!(
"(:label {} :flip-sign {} :roots (",
quote_string(&group.label),
if group.flip_sign { "t" } else { "nil" },
for (n, node) in group.roots.iter().enumerate() {
if n > 0 {
format_node_into(out, node);
fn format_breakdown(data: &BreakdownData) -> String {
let mut out = String::from("(:category-breakdown :meta ");
" :tag-name {} :periods (",
quote_string(&data.tag_name)
format_breakdown_period_into(&mut out, period);
fn format_breakdown_period_into(out: &mut String, period: &BreakdownPeriod) {
out.push_str(" :rows (");
for (idx, row) in period.rows.iter().enumerate() {
format_breakdown_row_into(out, row);
fn format_breakdown_row_into(out: &mut String, row: &BreakdownRow) {
"(:tag-value {} :uncategorized {} :amounts (",
quote_string(&row.tag_value),
if row.is_uncategorized { "t" } else { "nil" },
for (idx, amount) in row.amounts.iter().enumerate() {
"(:commodity-id \"{}\" :symbol {} :amount {})",
amount.commodity_id,
quote_string(&amount.commodity_symbol),
format_rational(&amount.amount),
fn format_meta(
date_from: Option<&DateTime<Utc>>,
date_to: Option<&DateTime<Utc>>,
target: Option<&uuid::Uuid>,
) -> String {
format!(
"(:date-from {} :date-to {} :target-commodity-id {})",
format_optional_rfc3339(date_from),
format_optional_rfc3339(date_to),
format_optional_uuid(target),
)
async fn run_balance_report(user_id: uuid::Uuid) -> wasmtime::Result<String> {
match BalanceReport::new().user_id(user_id).run().await {
Ok(Some(CmdResult::Report(data))) => Ok(format_balance(&data)),
"balance-report: expected Report, got {other:?}"
Ok(None) => Ok("(:balance-report :periods ())".to_string()),
Err(err) => Err(wasmtime::Error::msg(format!("balance-report: {err}"))),
fn format_balance(data: &ReportData) -> String {
let mut out = String::from("(:balance-report :periods (");
out.push_str(" :roots (");
for (n, node) in period.roots.iter().enumerate() {
format_node_into(&mut out, node);
fn format_node_into(out: &mut String, node: &ReportNode) {
"(:account-id \"{}\" :account-name {} :account-path {} :depth {} :account-type {} :amounts (",
node.account_id,
quote_string(&node.account_name),
quote_string(&node.account_path),
node.depth,
match node.account_type.as_deref() {
Some(t) => quote_string(t),
None => "nil".to_string(),
for (idx, amount) in node.amounts.iter().enumerate() {
out.push_str(") :children (");
for (idx, child) in node.children.iter().enumerate() {
format_node_into(out, child);
fn format_optional_rfc3339(ts: Option<&chrono::DateTime<chrono::Utc>>) -> String {
match ts {
Some(ts) => format!("\"{}\"", ts.to_rfc3339()),
fn format_optional_uuid(id: Option<&uuid::Uuid>) -> String {
match id {
Some(id) => format!("\"{id}\""),
fn format_rational(r: &Rational64) -> String {
if *r.denom() == 1 {
r.numer().to_string()
} else {
format!("{}/{}", r.numer(), r.denom())
fn quote_string(s: &str) -> String {
let mut q = String::with_capacity(s.len() + 2);
q.push('"');
for ch in s.chars() {
match ch {
'"' => q.push_str("\\\""),
'\\' => q.push_str("\\\\"),
other => q.push(other),
q
#[cfg(test)]
mod tests {
use super::*;
use server::command::{CommodityAmount, PeriodData, ReportMeta};
use uuid::Uuid;
#[tokio::test]
async fn run_activity_report_missing_from_emits_error() {
let err = run_activity_report(Uuid::nil(), None, Some("2026-01-01T00:00:00Z".into()))
.unwrap_err();
assert!(err.to_string().contains(":date-from"), "got: {err}");
async fn run_activity_report_invalid_date_emits_error() {
let err = run_activity_report(Uuid::nil(), Some("nope".into()), Some("nope".into()))
assert!(err.to_string().contains("invalid"), "got: {err}");
async fn run_category_breakdown_missing_from_emits_error() {
let err = run_category_breakdown(Uuid::nil(), None, Some("2026-01-01T00:00:00Z".into()))
async fn run_category_breakdown_invalid_date_emits_error() {
let err = run_category_breakdown(Uuid::nil(), Some("not-rfc3339".into()), Some("x".into()))
#[test]
fn format_balance_includes_all_commodity_amounts() {
let node = ReportNode {
account_id: Uuid::nil(),
account_name: "Assets".to_string(),
account_path: "Assets".to_string(),
depth: 0,
account_type: Some("asset".to_string()),
amounts: vec![
CommodityAmount {
commodity_id: Uuid::nil(),
commodity_symbol: "USD".to_string(),
amount: Rational64::new(100, 1),
commodity_symbol: "EUR".to_string(),
amount: Rational64::new(50, 1),
],
children: vec![],
let data = ReportData {
meta: ReportMeta {
date_from: None,
date_to: None,
target_commodity_id: None,
periods: vec![PeriodData {
label: None,
roots: vec![node],
}],
let s = format_balance(&data);
assert!(s.contains(":symbol \"USD\""), "USD must appear: {s}");
assert!(s.contains(":symbol \"EUR\""), "EUR must appear: {s}");
assert!(
s.contains(":balance-report :periods"),
"top-level shape: {s}"
);
assert!(s.contains(":roots"), "must have :roots key: {s}");