Lines
87.78 %
Functions
47.06 %
Branches
100 %
//! Commodity-domain natives. Wraps `server::command::{GetCommodity,
//! CreateCommodity, ListCommodities}`.
use finance::tag::Tag;
use scripting::runtime::{
alloc_commodity_ref, alloc_entity_via_export, alloc_pair_chain, alloc_string_ref,
read_commodity_arg, read_string_arg,
};
use server::command::commodity::{
ConvertCommodity, CreateCommodity, GetCommodity, ListCommodities,
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] = &[
"get-commodity",
"create-commodity",
"list-commodities",
"convert-commodity",
];
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",
"commodity_list_commodities",
|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 = ListCommodities::new().user_id(user_id).run().await;
let entities = list_commodity_entities("list-commodities", result)?;
alloc_commodity_chain(&mut caller, entities).await
})
},
)?;
"commodity_get_commodity",
(id_arg,): (Option<Rooted<ArrayRef>>,)|
let id = read_string_arg(&mut caller, id_arg)?;
run_get_commodity(&mut caller, user_id, id).await
"commodity_convert_commodity",
(amount_arg, target_arg): (Option<Rooted<StructRef>>, Option<Rooted<ArrayRef>>)|
let amount = read_commodity_arg(&mut caller, amount_arg)?;
let target = read_string_arg(&mut caller, target_arg)?;
let (numer, denom, target_id) = resolve_convert(user_id, amount, target).await?;
let ref_ = alloc_commodity_ref(&mut caller, numer, denom, target_id).await?;
Ok(Some(ref_))
pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
"commodity_create_commodity",
(symbol_arg, name_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
let symbol = read_string_arg(&mut caller, symbol_arg)?;
let name = read_string_arg(&mut caller, name_arg)?;
let id = run_create_commodity(user_id, symbol, name).await?;
Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
/// Companion to `get-commodity`. Looks up the most recent Price row
/// between source and target commodities, multiplies the supplied
/// amount, and returns the converted `(numer, denom, target_id)`.
/// Caller wraps the tuple into a `$commodity` ref via
/// `alloc_commodity_ref`. Surfaces `wasmtime::Error::msg` on
/// missing/invalid args or absent conversion path.
async fn resolve_convert(
user_id: Uuid,
amount_arg: Option<(i64, i64, Uuid)>,
target_arg: Option<String>,
) -> wasmtime::Result<(i64, i64, Uuid)> {
let (amount_num, amount_denom, source_id) = amount_arg.ok_or_else(|| {
wasmtime::Error::msg("convert-commodity: missing commodity-typed amount argument")
})?;
let raw = target_arg
.filter(|s| !s.is_empty())
.ok_or_else(|| wasmtime::Error::msg("convert-commodity: missing target commodity id"))?;
let target_id = Uuid::parse_str(&raw).map_err(|err| {
wasmtime::Error::msg(format!(
"convert-commodity: invalid target uuid '{raw}': {err}"
))
let result = ConvertCommodity::new()
.user_id(user_id)
.amount_num(amount_num)
.amount_denom(amount_denom)
.source_commodity_id(source_id)
.target_commodity_id(target_id)
.run()
.await
.map_err(|err| wasmtime::Error::msg(format!("convert-commodity: {err}")))?;
let rational = match result {
Some(CmdResult::Rational(r)) => r,
Some(other) => {
return Err(wasmtime::Error::msg(format!(
"convert-commodity: unexpected variant {other:?}"
)));
None => {
return Err(wasmtime::Error::msg(
"convert-commodity: command returned no rational",
));
Ok((*rational.numer(), *rational.denom(), target_id))
/// Writes a new commodity row for the session user with `symbol` and
/// `name` tags. Returns the new entity's UUID in `(:commodity-id "...")`.
/// Args ride the capture queue: compiler pushes symbol first, then name;
/// host pops via FIFO take_arg in matching order.
async fn run_create_commodity(
symbol_arg: Option<String>,
name_arg: Option<String>,
) -> wasmtime::Result<String> {
let symbol = symbol_arg
.ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :symbol arg"))?;
let name = name_arg
.ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :name arg"))?;
match CreateCommodity::new()
.symbol(symbol)
.name(name)
{
Ok(Some(CmdResult::String(id))) => Ok(id),
Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
"create-commodity: expected String id, got {other:?}"
))),
Ok(None) => Err(wasmtime::Error::msg(
"create-commodity: command returned no id",
)),
Err(err) => Err(wasmtime::Error::msg(format!("create-commodity: {err}"))),
async fn run_get_commodity(
caller: &mut Caller<'_, SessionData>,
id_arg: Option<String>,
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
let raw = id_arg
.ok_or_else(|| wasmtime::Error::msg("get-commodity: missing :commodity-id arg"))?;
// Accept a uuid OR a symbol, mirroring get-account's id/name fallback: a
// uuid arg is an id lookup (unchanged); a non-uuid arg is matched
// case-insensitively against commodity symbols, so `(get-commodity "USD")`
// works in templates without pasting a uuid. Like get-account, a
// uuid-SHAPED symbol is only reachable by its real id, not by the symbol
// string — an accepted, consistent limitation for a pathological name.
let entry = match Uuid::parse_str(&raw) {
Ok(commodity_id) => {
let result = GetCommodity::new()
.commodity_id(commodity_id)
.await;
list_commodity_entities("get-commodity", result)?
.into_iter()
.next()
Err(_) => resolve_commodity_symbol(user_id, &raw).await?,
match entry {
Some((id, symbol, name)) => Ok(Some(
alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?,
None => Ok(None),
/// Resolves a commodity by its symbol (case-insensitive) for `get-commodity`.
/// `None` when no symbol matches; an ERROR when more than one does — symbols
/// aren't unique in the schema, and silently binding to an arbitrary one would
/// draft a transaction against the wrong commodity. Failing loudly is the safe
/// choice for a finance value (and is stricter than `get-account`, which is
/// acceptable: a wrong currency is worse than a wrong account label).
async fn resolve_commodity_symbol(
symbol: &str,
) -> wasmtime::Result<Option<CommodityEntry>> {
let mut matches = list_commodity_entities("get-commodity", result)?
.filter(|(_, sym, _)| {
sym.as_deref()
.is_some_and(|s| s.eq_ignore_ascii_case(symbol))
});
let first = matches.next();
if first.is_some() && matches.next().is_some() {
"get-commodity: symbol '{symbol}' is ambiguous (multiple commodities \
share it); reference it by uuid instead"
Ok(first)
/// Unwraps a `CmdResult::TaggedEntities` and extracts the (id, symbol-tag,
/// (id, symbol-tag, name-tag) triple per commodity, flattened from
/// `TaggedEntities` so the wasm marshalling site walks one typed row
/// per commodity.
type CommodityEntry = (String, Option<String>, Option<String>);
/// name-tag) triple per commodity. Returns the typed shape the host fn
/// then folds through the entity allocator + pair chain. Wrong variant or
/// command error surfaces as `wasmtime::Error`.
fn list_commodity_entities(
name: &str,
result: Result<Option<CmdResult>, CmdError>,
) -> wasmtime::Result<Vec<CommodityEntry>> {
match result {
Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
.filter_map(|(entity, tags)| match entity {
FinanceEntity::Commodity(c) => Some((
c.id.to_string(),
tag_value(&tags, "symbol").map(str::to_string),
tag_value(&tags, "name").map(str::to_string),
_ => None,
.collect()),
"{name}: expected TaggedEntities, got {other:?}"
Ok(None) => Ok(Vec::new()),
Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
/// Re-enters wasm to construct a `$commodity_entity` struct ref carrying the
/// id, symbol-tag, and name-tag as `$i8_array` payloads. Missing tags ride
/// as null `(ref null $i8_array)`.
async fn alloc_commodity_entity(
id: &str,
symbol: Option<&str>,
name: Option<&str>,
) -> wasmtime::Result<Rooted<StructRef>> {
let id_ref = alloc_string_ref(caller, id.as_bytes())?;
let symbol_ref = match symbol {
Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
None => None,
let name_ref = match name {
let args = [
Val::AnyRef(Some(id_ref.to_anyref())),
Val::AnyRef(symbol_ref.map(|r| r.to_anyref())),
Val::AnyRef(name_ref.map(|r| r.to_anyref())),
alloc_entity_via_export(caller, "alloc_commodity_entity", &args).await
/// Allocates a pair chain of `$commodity_entity` refs from the typed
/// triples extracted by `list_commodity_entities`. Returns the chain head,
/// or `None` for an empty result set.
async fn alloc_commodity_chain(
entities: Vec<(String, Option<String>, Option<String>)>,
let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entities.len());
for (id, symbol, name) in entities {
let entity_ref =
alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?;
anyrefs.push(entity_ref.to_anyref());
alloc_pair_chain(caller, anyrefs).await
/// (Retired) Renders the TaggedEntities result as the previous self-capturing
/// string envelope; kept for the test fixtures still calling it. A6 deletes
/// it alongside the streaming-string capture protocol.
#[cfg(test)]
fn format_tagged_commodities(
entities: &[(
FinanceEntity,
std::collections::HashMap<String, FinanceEntity>,
)],
) -> String {
let mut out = String::from("(:commodities (");
for (idx, (entity, tags)) in entities.iter().enumerate() {
if idx > 0 {
out.push(' ');
let id = match entity {
FinanceEntity::Commodity(c) => c.id,
other => {
out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
continue;
out.push_str(&format!("(:id \"{id}\""));
if let Some(symbol) = tag_value(tags, "symbol") {
out.push_str(&format!(" :symbol {}", quote_string(symbol)));
if let Some(name) = tag_value(tags, "name") {
out.push_str(&format!(" :name {}", quote_string(name)));
out.push(')');
out.push_str("))");
out
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::commodity::Commodity;
use std::collections::HashMap;
fn commodity_entity(id: Uuid) -> FinanceEntity {
FinanceEntity::Commodity(Commodity { id })
#[test]
fn format_empty_list() {
assert_eq!(format_tagged_commodities(&[]), "(:commodities ())");
fn format_single_commodity_with_symbol_and_name() {
let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let mut tags = HashMap::new();
tags.insert(
"symbol".to_string(),
FinanceEntity::Tag(Tag {
id: Uuid::nil(),
tag_name: "symbol".into(),
tag_value: "USD".into(),
description: None,
}),
);
"name".to_string(),
tag_name: "name".into(),
tag_value: "US Dollar".into(),
let out = format_tagged_commodities(&[(commodity_entity(id), tags)]);
assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
assert!(out.contains(":symbol \"USD\""));
assert!(out.contains(":name \"US Dollar\""));
#[tokio::test]
async fn run_create_commodity_missing_symbol_emits_error() {
let err = run_create_commodity(Uuid::nil(), None, Some("name".into()))
.unwrap_err();
assert!(err.to_string().contains(":symbol"));
async fn run_create_commodity_missing_name_emits_error() {
let err = run_create_commodity(Uuid::nil(), Some("sym".into()), None)
assert!(err.to_string().contains(":name"));
fn format_commodity_without_tags_emits_id_only() {
let id = Uuid::nil();
let out = format_tagged_commodities(&[(commodity_entity(id), HashMap::new())]);
assert_eq!(
out,
"(:commodities ((:id \"00000000-0000-0000-0000-000000000000\")))"