Lines
91.85 %
Functions
39.42 %
Branches
100 %
//! Split-domain natives. Wraps `server::command::ListSplits`.
//!
//! `list-splits` returns a typed `pair<split>` of `$split` entity refs;
//! each carries id, account-id, commodity-id, and value (Ratio). The
//! `value_denom` ride alongside via the Ratio field. Notes / reconcile
//! state stay reachable from the underlying `Tag` rows but are not
//! surfaced on the typed entity yet — extend the `$split` shape +
//! `nomi_entity!` declaration to expose more fields.
#[cfg(test)]
use finance::tag::Tag;
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::split::{GetSplitTag, ListSplits, SetSplitTag};
use server::command::{CmdError, CmdResult, FinanceEntity};
use uuid::Uuid;
use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
use crate::session::SessionData;
pub const REGISTERED_COMMANDS: &[&str] = &[
"list-splits",
"list-splits-by-transaction",
"set-split-tag",
"get-split-tag",
];
pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
register_readonly(linker)?;
register_mutators(linker)?;
Ok(())
}
pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
linker.func_wrap_async(
"nomi",
"split_list_splits",
|mut caller: Caller<'_, SessionData>,
(id_arg,): (Option<Rooted<wasmtime::ArrayRef>>,)|
-> 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 id = read_string_arg(&mut caller, id_arg)?;
let account_id = parse_account_id_arg(id)?;
let result = ListSplits::new()
.user_id(user_id)
.account(account_id)
.run()
.await;
let entries = list_split_entries("list-splits", result)?;
alloc_split_chain(&mut caller, entries).await
})
},
)?;
"split_list_splits_by_transaction",
let transaction_id = parse_transaction_id_arg(id)?;
.transaction(transaction_id)
let entries = list_split_entries("list-splits-by-transaction", result)?;
"split_get_split_tag",
(id_arg, name_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
let name = read_string_arg(&mut caller, name_arg)?;
let value = run_get_split_tag(user_id, id, name).await?;
Ok(Some(alloc_string_ref(&mut caller, value.as_bytes())?))
pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
"split_set_split_tag",
(id_arg, name_arg, value_arg): super::StringArgTriple|
-> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
let value = read_string_arg(&mut caller, value_arg)?;
run_set_split_tag(user_id, id, name, value).await
/// `set-split-tag` upsert: either a fresh `(split, tag_name, tag_value)`
/// link or replace the existing value for that name. Returns 1 on
/// success; the i32 return matches `set-account-tag`'s shape so a
/// future error-cell migration only has to update one wire format.
async fn run_set_split_tag(
user_id: Uuid,
id_arg: Option<String>,
name_arg: Option<String>,
value_arg: Option<String>,
) -> wasmtime::Result<i32> {
let raw = id_arg
.filter(|s| !s.is_empty())
.ok_or_else(|| wasmtime::Error::msg("set-split-tag: missing or empty :split-id arg"))?;
let split_id = Uuid::parse_str(&raw).map_err(|err| {
wasmtime::Error::msg(format!("set-split-tag: invalid uuid '{raw}': {err}"))
})?;
let tag_name = name_arg
.ok_or_else(|| wasmtime::Error::msg("set-split-tag: missing or empty :tag-name arg"))?;
let tag_value =
value_arg.ok_or_else(|| wasmtime::Error::msg("set-split-tag: missing :tag-value arg"))?;
SetSplitTag::new()
.split_id(split_id)
.tag_name(tag_name)
.tag_value(tag_value)
.await
.map(|_| 1)
.map_err(|err| wasmtime::Error::msg(format!("set-split-tag: {err}")))
/// `get-split-tag` lookup. Returns the empty string when the tag isn't
/// set on this split — keeps the wasm contract simple (`string` return,
/// no `Option` boxing). Scripts test absence with `(equal? v "")`.
async fn run_get_split_tag(
) -> wasmtime::Result<String> {
.ok_or_else(|| wasmtime::Error::msg("get-split-tag: missing or empty :split-id arg"))?;
wasmtime::Error::msg(format!("get-split-tag: invalid uuid '{raw}': {err}"))
.ok_or_else(|| wasmtime::Error::msg("get-split-tag: missing or empty :tag-name arg"))?;
match GetSplitTag::new()
{
Ok(Some(CmdResult::String(s))) => Ok(s),
Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
"get-split-tag: expected String, got {other:?}"
))),
Ok(None) => Ok(String::new()),
Err(err) => Err(wasmtime::Error::msg(format!("get-split-tag: {err}"))),
/// Validates the :account-id arg into a UUID. Extracted so the validation
/// contract is reachable from unit tests without a wasmtime `Caller`.
fn parse_account_id_arg(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
.ok_or_else(|| wasmtime::Error::msg("list-splits: missing or empty :account-id arg"))?;
Uuid::parse_str(&raw)
.map_err(|err| wasmtime::Error::msg(format!("list-splits: invalid uuid '{raw}': {err}")))
/// Validates the :transaction-id arg into a UUID. Mirror of
/// [`parse_account_id_arg`] for the transaction-keyed splits query — the
/// server's `ListSplits` runs the by-transaction SQL when given a
/// `.transaction()` filter, but `list-splits` only ever wired the account
/// filter; this is the validation seam for the transaction variant.
fn parse_transaction_id_arg(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
wasmtime::Error::msg("list-splits-by-transaction: missing or empty :transaction-id arg")
Uuid::parse_str(&raw).map_err(|err| {
wasmtime::Error::msg(format!(
"list-splits-by-transaction: invalid uuid '{raw}': {err}"
))
/// (id, account_id, commodity_id, value_num, value_denom) per split,
/// flattened from `TaggedEntities` for the wasm allocator loop. Splits
/// don't carry tags on the wire (yet), so the tuple omits the tag map.
type SplitEntry = (String, String, String, i64, i64);
fn list_split_entries(
name: &str,
result: Result<Option<CmdResult>, CmdError>,
) -> wasmtime::Result<Vec<SplitEntry>> {
match result {
Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
.into_iter()
.filter_map(|(entity, _)| match entity {
FinanceEntity::Split(s) => Some((
s.id.to_string(),
s.account_id.to_string(),
s.commodity_id.to_string(),
s.value_num,
s.value_denom,
)),
_ => None,
.collect()),
"{name}: expected TaggedEntities, got {other:?}"
Ok(None) => Ok(Vec::new()),
Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
async fn alloc_split_entity(
caller: &mut Caller<'_, SessionData>,
id: &str,
account_id: &str,
commodity_id: &str,
value_num: i64,
value_denom: i64,
) -> wasmtime::Result<Rooted<StructRef>> {
let id_ref = alloc_string_ref(caller, id.as_bytes())?;
let account_ref = alloc_string_ref(caller, account_id.as_bytes())?;
let commodity_ref = alloc_string_ref(caller, commodity_id.as_bytes())?;
let ratio_ref = alloc_ratio_ref(caller, value_num, value_denom)?;
let args = [
Val::AnyRef(Some(id_ref.to_anyref())),
Val::AnyRef(Some(account_ref.to_anyref())),
Val::AnyRef(Some(commodity_ref.to_anyref())),
Val::AnyRef(Some(ratio_ref.to_anyref())),
alloc_entity_via_export(caller, "alloc_split", &args).await
async fn alloc_split_chain(
entries: Vec<(String, String, String, i64, i64)>,
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
for (id, account_id, commodity_id, num, denom) in entries {
let entity_ref =
alloc_split_entity(caller, &id, &account_id, &commodity_id, num, denom).await?;
anyrefs.push(entity_ref.to_anyref());
alloc_pair_chain(caller, anyrefs).await
fn format_splits(
entities: &[(
FinanceEntity,
std::collections::HashMap<String, FinanceEntity>,
)],
) -> String {
let mut out = String::from("(:splits (");
for (idx, (entity, tags)) in entities.iter().enumerate() {
if idx > 0 {
out.push(' ');
match entity {
FinanceEntity::Split(s) => {
let value = format_rational(&Rational64::new(s.value_num, s.value_denom));
let reconciled = match s.reconcile_state {
Some(true) => "t",
Some(false) | None => "nil",
out.push_str(&format!(
"(:id \"{}\" :transaction-id \"{}\" :account-id \"{}\" :commodity-id \"{}\" :value {} :reconciled {}",
s.id, s.tx_id, s.account_id, s.commodity_id, value, reconciled
));
if let Some(note) = tag_value(tags, "note") {
out.push_str(&format!(" :note {}", quote_string(note)));
out.push(')');
other => {
out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
out.push_str("))");
out
fn format_rational(r: &Rational64) -> String {
if *r.denom() == 1 {
r.numer().to_string()
} else {
format!("{}/{}", r.numer(), r.denom())
fn tag_value<'a>(
tags: &'a std::collections::HashMap<String, FinanceEntity>,
key: &str,
) -> Option<&'a str> {
tags.get(key).and_then(|t| match t {
FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
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
mod tests {
use super::*;
use finance::split::Split;
use std::collections::HashMap;
fn split(value_num: i64, value_denom: i64, reconciled: Option<bool>) -> FinanceEntity {
FinanceEntity::Split(Split {
id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
tx_id: Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(),
account_id: Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap(),
commodity_id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
reconcile_state: reconciled,
reconcile_date: None,
value_num,
value_denom,
lot_id: None,
#[test]
fn format_empty_splits_list() {
assert_eq!(format_splits(&[]), "(:splits ())");
fn format_single_split_no_tags_reconciled() {
let out = format_splits(&[(split(1500, 100, Some(true)), HashMap::new())]);
assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
assert!(out.contains(":transaction-id \"11111111-1111-1111-1111-111111111111\""));
assert!(out.contains(":account-id \"22222222-2222-2222-2222-222222222222\""));
assert!(out.contains(":commodity-id \"33333333-3333-3333-3333-333333333333\""));
assert!(out.contains(":value 15"));
assert!(out.contains(":reconciled t"));
assert!(!out.contains(":note"));
fn format_split_unreconciled_and_with_note_tag() {
let mut tags = HashMap::new();
tags.insert(
"note".to_string(),
FinanceEntity::Tag(Tag {
id: Uuid::nil(),
tag_name: "note".into(),
tag_value: "lunch".into(),
description: None,
}),
);
let out = format_splits(&[(split(7, 3, None), tags)]);
assert!(out.contains(":value 7/3"));
assert!(out.contains(":reconciled nil"));
assert!(out.contains(":note \"lunch\""));
fn parse_account_id_rejects_missing() {
let err = parse_account_id_arg(None).unwrap_err();
assert!(err.to_string().contains("missing or empty"), "got: {err}");
fn parse_account_id_rejects_invalid_uuid() {
let err = parse_account_id_arg(Some("nope".into())).unwrap_err();
assert!(err.to_string().contains("invalid uuid"), "got: {err}");
fn parse_transaction_id_rejects_missing() {
let err = parse_transaction_id_arg(None).unwrap_err();
assert!(
err.to_string()
.contains("list-splits-by-transaction: missing or empty"),
"got: {err}"
fn parse_transaction_id_rejects_invalid_uuid() {
let err = parse_transaction_id_arg(Some("nope".into())).unwrap_err();
.contains("list-splits-by-transaction: invalid uuid"),
fn parse_transaction_id_accepts_valid_uuid() {
let uuid = "11111111-1111-1111-1111-111111111111";
let parsed = parse_transaction_id_arg(Some(uuid.into())).unwrap();
assert_eq!(parsed, Uuid::parse_str(uuid).unwrap());