1use finance::tag::Tag;
5use scripting::runtime::{
6 alloc_commodity_ref, alloc_entity_via_export, alloc_pair_chain, alloc_string_ref,
7 read_commodity_arg, read_string_arg,
8};
9use server::command::commodity::{
10 ConvertCommodity, CreateCommodity, GetCommodity, ListCommodities,
11};
12use server::command::{CmdError, CmdResult, FinanceEntity};
13use uuid::Uuid;
14use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
15
16use crate::session::SessionData;
17
18pub const REGISTERED_COMMANDS: &[&str] = &[
19 "get-commodity",
20 "create-commodity",
21 "list-commodities",
22 "convert-commodity",
23 "convert-amount",
24];
25
26pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
27 register_readonly(linker)?;
28 register_mutators(linker)?;
29 Ok(())
30}
31
32pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
33 linker.func_wrap_async(
34 "nomi",
35 "commodity_list_commodities",
36 |mut caller: Caller<'_, SessionData>,
37 ()|
38 -> Box<
39 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
40 > {
41 Box::new(async move {
42 let user_id = caller.data().ctx().user_id;
43 let result = ListCommodities::new().user_id(user_id).run().await;
44 let entities = list_commodity_entities("list-commodities", result)?;
45 alloc_commodity_chain(&mut caller, entities).await
46 })
47 },
48 )?;
49 linker.func_wrap_async(
50 "nomi",
51 "commodity_get_commodity",
52 |mut caller: Caller<'_, SessionData>,
53 (id_arg,): (Option<Rooted<ArrayRef>>,)|
54 -> Box<
55 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
56 > {
57 Box::new(async move {
58 let user_id = caller.data().ctx().user_id;
59 let id = read_string_arg(&mut caller, id_arg)?;
60 run_get_commodity(&mut caller, user_id, id).await
61 })
62 },
63 )?;
64 linker.func_wrap_async(
65 "nomi",
66 "commodity_convert_commodity",
67 |mut caller: Caller<'_, SessionData>,
68 (amount_arg, target_arg): (Option<Rooted<StructRef>>, Option<Rooted<ArrayRef>>)|
69 -> Box<
70 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
71 > {
72 Box::new(async move {
73 let user_id = caller.data().ctx().user_id;
74 let amount = read_commodity_arg(&mut caller, amount_arg)?;
75 let target = read_string_arg(&mut caller, target_arg)?;
76 let (numer, denom, target_id) = resolve_convert(user_id, amount, target).await?;
77 let ref_ = alloc_commodity_ref(&mut caller, numer, denom, target_id).await?;
78 Ok(Some(ref_))
79 })
80 },
81 )?;
82 linker.func_wrap_async(
83 "nomi",
84 "commodity_convert_amount",
85 |mut caller: Caller<'_, SessionData>,
86 (amount_arg, from_arg, to_arg): super::StringArgTriple|
87 -> Box<
88 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
89 > {
90 Box::new(async move {
91 let user_id = caller.data().ctx().user_id;
92 let amount_str = read_string_arg(&mut caller, amount_arg)?.unwrap_or_default();
93 let from_str = read_string_arg(&mut caller, from_arg)?
94 .filter(|s| !s.is_empty())
95 .ok_or_else(|| {
96 wasmtime::Error::msg("convert-amount: missing from-commodity id")
97 })?;
98 let to_str = read_string_arg(&mut caller, to_arg)?;
99 let (num, denom) = parse_amount_str(&amount_str)?;
100 let from_id = Uuid::parse_str(&from_str).map_err(|err| {
101 wasmtime::Error::msg(format!(
102 "convert-amount: invalid from-commodity uuid '{from_str}': {err}"
103 ))
104 })?;
105 let (result_num, result_denom, _) =
106 resolve_convert(user_id, Some((num, denom, from_id)), to_str).await?;
107 let result = if result_denom == 1 {
108 result_num.to_string()
109 } else {
110 format!("{result_num}/{result_denom}")
111 };
112 Ok(Some(alloc_string_ref(&mut caller, result.as_bytes())?))
113 })
114 },
115 )?;
116 Ok(())
117}
118
119pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
120 linker.func_wrap_async(
121 "nomi",
122 "commodity_create_commodity",
123 |mut caller: Caller<'_, SessionData>,
124 (symbol_arg, name_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
125 -> Box<
126 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
127 > {
128 Box::new(async move {
129 let user_id = caller.data().ctx().user_id;
130 let symbol = read_string_arg(&mut caller, symbol_arg)?;
131 let name = read_string_arg(&mut caller, name_arg)?;
132 let id = run_create_commodity(user_id, symbol, name).await?;
133 Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
134 })
135 },
136 )?;
137 Ok(())
138}
139
140async fn resolve_convert(
147 user_id: Uuid,
148 amount_arg: Option<(i64, i64, Uuid)>,
149 target_arg: Option<String>,
150) -> wasmtime::Result<(i64, i64, Uuid)> {
151 let (amount_num, amount_denom, source_id) = amount_arg.ok_or_else(|| {
152 wasmtime::Error::msg("convert-commodity: missing commodity-typed amount argument")
153 })?;
154 let raw = target_arg
155 .filter(|s| !s.is_empty())
156 .ok_or_else(|| wasmtime::Error::msg("convert-commodity: missing target commodity id"))?;
157 let target_id = Uuid::parse_str(&raw).map_err(|err| {
158 wasmtime::Error::msg(format!(
159 "convert-commodity: invalid target uuid '{raw}': {err}"
160 ))
161 })?;
162 let result = ConvertCommodity::new()
163 .user_id(user_id)
164 .amount_num(amount_num)
165 .amount_denom(amount_denom)
166 .source_commodity_id(source_id)
167 .target_commodity_id(target_id)
168 .run()
169 .await
170 .map_err(|err| wasmtime::Error::msg(format!("convert-commodity: {err}")))?;
171 let rational = match result {
172 Some(CmdResult::Rational(r)) => r,
173 Some(other) => {
174 return Err(wasmtime::Error::msg(format!(
175 "convert-commodity: unexpected variant {other:?}"
176 )));
177 }
178 None => {
179 return Err(wasmtime::Error::msg(
180 "convert-commodity: command returned no rational",
181 ));
182 }
183 };
184 Ok((*rational.numer(), *rational.denom(), target_id))
185}
186
187fn parse_amount_str(s: &str) -> wasmtime::Result<(i64, i64)> {
192 if s.is_empty() {
193 return Err(wasmtime::Error::msg(
194 "convert-amount: invalid amount '': empty string",
195 ));
196 }
197 match s.split_once('/') {
198 None => {
199 let n = s.parse::<i64>().map_err(|_| {
200 wasmtime::Error::msg(format!("convert-amount: invalid amount '{s}'"))
201 })?;
202 Ok((n, 1))
203 }
204 Some((n_str, d_str)) => {
205 let n = n_str.parse::<i64>().map_err(|_| {
206 wasmtime::Error::msg(format!("convert-amount: invalid amount '{s}'"))
207 })?;
208 let d = d_str.parse::<i64>().map_err(|_| {
209 wasmtime::Error::msg(format!("convert-amount: invalid amount '{s}'"))
210 })?;
211 if d <= 0 {
212 return Err(wasmtime::Error::msg(
213 "convert-amount: denominator must be positive",
214 ));
215 }
216 Ok((n, d))
217 }
218 }
219}
220
221async fn run_create_commodity(
226 user_id: Uuid,
227 symbol_arg: Option<String>,
228 name_arg: Option<String>,
229) -> wasmtime::Result<String> {
230 let symbol = symbol_arg
231 .filter(|s| !s.is_empty())
232 .ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :symbol arg"))?;
233 let name = name_arg
234 .filter(|s| !s.is_empty())
235 .ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :name arg"))?;
236 match CreateCommodity::new()
237 .symbol(symbol)
238 .name(name)
239 .user_id(user_id)
240 .run()
241 .await
242 {
243 Ok(Some(CmdResult::String(id))) => Ok(id),
244 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
245 "create-commodity: expected String id, got {other:?}"
246 ))),
247 Ok(None) => Err(wasmtime::Error::msg(
248 "create-commodity: command returned no id",
249 )),
250 Err(err) => Err(wasmtime::Error::msg(format!("create-commodity: {err}"))),
251 }
252}
253
254async fn run_get_commodity(
255 caller: &mut Caller<'_, SessionData>,
256 user_id: Uuid,
257 id_arg: Option<String>,
258) -> wasmtime::Result<Option<Rooted<StructRef>>> {
259 let raw = id_arg
260 .filter(|s| !s.is_empty())
261 .ok_or_else(|| wasmtime::Error::msg("get-commodity: missing :commodity-id arg"))?;
262
263 let entry = match Uuid::parse_str(&raw) {
270 Ok(commodity_id) => {
271 let result = GetCommodity::new()
272 .user_id(user_id)
273 .commodity_id(commodity_id)
274 .run()
275 .await;
276 list_commodity_entities("get-commodity", result)?
277 .into_iter()
278 .next()
279 }
280 Err(_) => resolve_commodity_symbol(user_id, &raw).await?,
281 };
282
283 match entry {
284 Some((id, symbol, name)) => Ok(Some(
285 alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?,
286 )),
287 None => Ok(None),
288 }
289}
290
291async fn resolve_commodity_symbol(
298 user_id: Uuid,
299 symbol: &str,
300) -> wasmtime::Result<Option<CommodityEntry>> {
301 let result = ListCommodities::new().user_id(user_id).run().await;
302 let mut matches = list_commodity_entities("get-commodity", result)?
303 .into_iter()
304 .filter(|(_, sym, _)| {
305 sym.as_deref()
306 .is_some_and(|s| s.eq_ignore_ascii_case(symbol))
307 });
308 let first = matches.next();
309 if first.is_some() && matches.next().is_some() {
310 return Err(wasmtime::Error::msg(format!(
311 "get-commodity: symbol '{symbol}' is ambiguous (multiple commodities \
312 share it); reference it by uuid instead"
313 )));
314 }
315 Ok(first)
316}
317
318type CommodityEntry = (String, Option<String>, Option<String>);
323
324fn list_commodity_entities(
328 name: &str,
329 result: Result<Option<CmdResult>, CmdError>,
330) -> wasmtime::Result<Vec<CommodityEntry>> {
331 match result {
332 Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
333 .into_iter()
334 .filter_map(|(entity, tags)| match entity {
335 FinanceEntity::Commodity(c) => Some((
336 c.id.to_string(),
337 tag_value(&tags, "symbol").map(str::to_string),
338 tag_value(&tags, "name").map(str::to_string),
339 )),
340 _ => None,
341 })
342 .collect()),
343 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
344 "{name}: expected TaggedEntities, got {other:?}"
345 ))),
346 Ok(None) => Ok(Vec::new()),
347 Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
348 }
349}
350
351async fn alloc_commodity_entity(
355 caller: &mut Caller<'_, SessionData>,
356 id: &str,
357 symbol: Option<&str>,
358 name: Option<&str>,
359) -> wasmtime::Result<Rooted<StructRef>> {
360 let id_ref = alloc_string_ref(caller, id.as_bytes())?;
361 let symbol_ref = match symbol {
362 Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
363 None => None,
364 };
365 let name_ref = match name {
366 Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
367 None => None,
368 };
369 let args = [
370 Val::AnyRef(Some(id_ref.to_anyref())),
371 Val::AnyRef(symbol_ref.map(|r| r.to_anyref())),
372 Val::AnyRef(name_ref.map(|r| r.to_anyref())),
373 ];
374 alloc_entity_via_export(caller, "alloc_commodity_entity", &args).await
375}
376
377async fn alloc_commodity_chain(
381 caller: &mut Caller<'_, SessionData>,
382 entities: Vec<(String, Option<String>, Option<String>)>,
383) -> wasmtime::Result<Option<Rooted<StructRef>>> {
384 let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entities.len());
385 for (id, symbol, name) in entities {
386 let entity_ref =
387 alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?;
388 anyrefs.push(entity_ref.to_anyref());
389 }
390 alloc_pair_chain(caller, anyrefs).await
391}
392
393fn tag_value<'a>(
394 tags: &'a std::collections::HashMap<String, FinanceEntity>,
395 key: &str,
396) -> Option<&'a str> {
397 tags.get(key).and_then(|t| match t {
398 FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
399 _ => None,
400 })
401}
402
403#[cfg(test)]
404mod tests;