Lines
65.27 %
Functions
36.97 %
Branches
100 %
//! Report-domain natives. Wraps `server::command::{BalanceReport, ActivityReport,
//! CategoryBreakdown}`.
//!
//! v1 binds the no-arg snapshot form of `balance-report`: no commodity
//! filter, no date range, no `ReportFilter`. Surfaces the resulting
//! `ReportData` tree as a nested plist so emacs `(read)` walks
//! ((:account-id ... :children (...)) ...) recursively. Date-range and
//! commodity-filter variants ride follow-up slices once the
//! keyword-pair extension to the capture queue lands; activity-report
//! and category-breakdown have similar shapes and wait on the same
//! groundwork.
use chrono::{DateTime, Utc};
use num_rational::Rational64;
use scripting::runtime::{
alloc_entity_via_export, alloc_pair_chain, alloc_ratio_ref, alloc_string_ref, read_string_arg,
};
use server::command::report::{ActivityReport, BalanceReport, CategoryBreakdown};
use server::command::{
ActivityData, ActivityPeriod, BreakdownData, BreakdownPeriod, BreakdownRow, CmdError,
CmdResult, ReportNode,
use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
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<StructRef>>>> + Send,
> {
Box::new(async move {
let user_id = caller.data().ctx().user_id;
let result = BalanceReport::new().user_id(user_id).run().await;
balance_report_to_entity(&mut caller, result).await
})
},
)?;
"report_activity_report",
(from_arg, to_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
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?;
Ok(Some(alloc_string_ref(&mut caller, payload.as_bytes())?))
"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),
)
/// Surfaces a `ReportData` tree as a typed `$report_node` entity. Wraps
/// all period roots under a single synthetic root (depth 0, empty
/// label, zero amount) so the consumer walks one entity ref — periods
/// flatten because the typed-entity shape doesn't (yet) model periods
/// directly. Multi-currency amounts collapse to the first row's value;
/// truly multi-currency reports still need a follow-up sub-slice to
/// surface `(node-amounts ...)` as a `pair<commodity>`.
async fn balance_report_to_entity(
caller: &mut Caller<'_, SessionData>,
result: Result<Option<CmdResult>, CmdError>,
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
let data = match result {
Ok(Some(CmdResult::Report(d))) => d,
Ok(Some(other)) => {
return Err(wasmtime::Error::msg(format!(
"balance-report: expected Report, got {other:?}"
)));
Ok(None) => return Ok(None),
Err(err) => return Err(wasmtime::Error::msg(format!("balance-report: {err}"))),
let mut child_refs: Vec<Rooted<AnyRef>> = Vec::new();
for period in &data.periods {
for root in &period.roots {
let node = alloc_report_node_tree(caller, root).await?;
child_refs.push(node.to_anyref());
let children = alloc_pair_chain(caller, child_refs).await?;
let id = alloc_string_ref(caller, b"")?;
let label = alloc_string_ref(caller, b"balance-report")?;
let amount = alloc_ratio_ref(caller, 0, 1)?;
let root = alloc_entity_via_export(
caller,
"alloc_report_node",
&[
Val::AnyRef(Some(id.to_anyref())),
Val::AnyRef(Some(label.to_anyref())),
Val::I32(0),
Val::AnyRef(Some(amount.to_anyref())),
Val::AnyRef(children.map(|p| p.to_anyref())),
],
.await?;
Ok(Some(root))
/// Stringification of a `ReportNode` for the not-yet-typed report
/// natives (activity, category-breakdown). Once those migrate to
/// typed `$report_node` returns, this fallback retires.
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);
/// Recursively allocates a `$report_node` wasm struct for `node` and
/// every descendant. Async recursion needs an explicit `Box::pin`
/// because `async fn` desugars to an opaque future whose size can't be
/// known at compile time when the body references itself.
fn alloc_report_node_tree<'a>(
caller: &'a mut Caller<'_, SessionData>,
node: &'a ReportNode,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = wasmtime::Result<Rooted<StructRef>>> + Send + 'a>,
Box::pin(async move {
let mut child_refs: Vec<Rooted<AnyRef>> = Vec::with_capacity(node.children.len());
for child in &node.children {
let r = alloc_report_node_tree(caller, child).await?;
child_refs.push(r.to_anyref());
let id = alloc_string_ref(caller, node.account_id.to_string().as_bytes())?;
let label = alloc_string_ref(caller, node.account_name.as_bytes())?;
let primary = node
.amounts
.first()
.map_or(Rational64::new_raw(0, 1), |a| a.amount);
let amount = alloc_ratio_ref(caller, *primary.numer(), *primary.denom())?;
let depth = i32::try_from(node.depth).unwrap_or(i32::MAX);
alloc_entity_via_export(
Val::I32(depth),
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 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()))
// The string-flattener test that lived here covered the old
// `format_report` path; balance-report now returns a typed
// `$report_node` entity, so equivalent coverage lives in
// `tests-integration` where a live `rpc::Session` can produce a
// real `Rooted<StructRef>` and consumers compose it through
// `(node-children ...)` / `(node-label ...)`.