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];
24
25pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
26 register_readonly(linker)?;
27 register_mutators(linker)?;
28 Ok(())
29}
30
31pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
32 linker.func_wrap_async(
33 "nomi",
34 "commodity_list_commodities",
35 |mut caller: Caller<'_, SessionData>,
36 ()|
37 -> Box<
38 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
39 > {
40 Box::new(async move {
41 let user_id = caller.data().ctx().user_id;
42 let result = ListCommodities::new().user_id(user_id).run().await;
43 let entities = list_commodity_entities("list-commodities", result)?;
44 alloc_commodity_chain(&mut caller, entities).await
45 })
46 },
47 )?;
48 linker.func_wrap_async(
49 "nomi",
50 "commodity_get_commodity",
51 |mut caller: Caller<'_, SessionData>,
52 (id_arg,): (Option<Rooted<ArrayRef>>,)|
53 -> Box<
54 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
55 > {
56 Box::new(async move {
57 let user_id = caller.data().ctx().user_id;
58 let id = read_string_arg(&mut caller, id_arg)?;
59 run_get_commodity(&mut caller, user_id, id).await
60 })
61 },
62 )?;
63 linker.func_wrap_async(
64 "nomi",
65 "commodity_convert_commodity",
66 |mut caller: Caller<'_, SessionData>,
67 (amount_arg, target_arg): (Option<Rooted<StructRef>>, Option<Rooted<ArrayRef>>)|
68 -> Box<
69 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
70 > {
71 Box::new(async move {
72 let user_id = caller.data().ctx().user_id;
73 let amount = read_commodity_arg(&mut caller, amount_arg)?;
74 let target = read_string_arg(&mut caller, target_arg)?;
75 let (numer, denom, target_id) = resolve_convert(user_id, amount, target).await?;
76 let ref_ = alloc_commodity_ref(&mut caller, numer, denom, target_id).await?;
77 Ok(Some(ref_))
78 })
79 },
80 )?;
81 Ok(())
82}
83
84pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
85 linker.func_wrap_async(
86 "nomi",
87 "commodity_create_commodity",
88 |mut caller: Caller<'_, SessionData>,
89 (symbol_arg, name_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
90 -> Box<
91 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
92 > {
93 Box::new(async move {
94 let user_id = caller.data().ctx().user_id;
95 let symbol = read_string_arg(&mut caller, symbol_arg)?;
96 let name = read_string_arg(&mut caller, name_arg)?;
97 let id = run_create_commodity(user_id, symbol, name).await?;
98 Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
99 })
100 },
101 )?;
102 Ok(())
103}
104
105async fn resolve_convert(
112 user_id: Uuid,
113 amount_arg: Option<(i64, i64, Uuid)>,
114 target_arg: Option<String>,
115) -> wasmtime::Result<(i64, i64, Uuid)> {
116 let (amount_num, amount_denom, source_id) = amount_arg.ok_or_else(|| {
117 wasmtime::Error::msg("convert-commodity: missing commodity-typed amount argument")
118 })?;
119 let raw = target_arg
120 .filter(|s| !s.is_empty())
121 .ok_or_else(|| wasmtime::Error::msg("convert-commodity: missing target commodity id"))?;
122 let target_id = Uuid::parse_str(&raw).map_err(|err| {
123 wasmtime::Error::msg(format!(
124 "convert-commodity: invalid target uuid '{raw}': {err}"
125 ))
126 })?;
127 let result = ConvertCommodity::new()
128 .user_id(user_id)
129 .amount_num(amount_num)
130 .amount_denom(amount_denom)
131 .source_commodity_id(source_id)
132 .target_commodity_id(target_id)
133 .run()
134 .await
135 .map_err(|err| wasmtime::Error::msg(format!("convert-commodity: {err}")))?;
136 let rational = match result {
137 Some(CmdResult::Rational(r)) => r,
138 Some(other) => {
139 return Err(wasmtime::Error::msg(format!(
140 "convert-commodity: unexpected variant {other:?}"
141 )));
142 }
143 None => {
144 return Err(wasmtime::Error::msg(
145 "convert-commodity: command returned no rational",
146 ));
147 }
148 };
149 Ok((*rational.numer(), *rational.denom(), target_id))
150}
151
152async fn run_create_commodity(
157 user_id: Uuid,
158 symbol_arg: Option<String>,
159 name_arg: Option<String>,
160) -> wasmtime::Result<String> {
161 let symbol = symbol_arg
162 .filter(|s| !s.is_empty())
163 .ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :symbol arg"))?;
164 let name = name_arg
165 .filter(|s| !s.is_empty())
166 .ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :name arg"))?;
167 match CreateCommodity::new()
168 .symbol(symbol)
169 .name(name)
170 .user_id(user_id)
171 .run()
172 .await
173 {
174 Ok(Some(CmdResult::String(id))) => Ok(id),
175 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
176 "create-commodity: expected String id, got {other:?}"
177 ))),
178 Ok(None) => Err(wasmtime::Error::msg(
179 "create-commodity: command returned no id",
180 )),
181 Err(err) => Err(wasmtime::Error::msg(format!("create-commodity: {err}"))),
182 }
183}
184
185async fn run_get_commodity(
186 caller: &mut Caller<'_, SessionData>,
187 user_id: Uuid,
188 id_arg: Option<String>,
189) -> wasmtime::Result<Option<Rooted<StructRef>>> {
190 let raw = id_arg
191 .filter(|s| !s.is_empty())
192 .ok_or_else(|| wasmtime::Error::msg("get-commodity: missing :commodity-id arg"))?;
193
194 let entry = match Uuid::parse_str(&raw) {
201 Ok(commodity_id) => {
202 let result = GetCommodity::new()
203 .user_id(user_id)
204 .commodity_id(commodity_id)
205 .run()
206 .await;
207 list_commodity_entities("get-commodity", result)?
208 .into_iter()
209 .next()
210 }
211 Err(_) => resolve_commodity_symbol(user_id, &raw).await?,
212 };
213
214 match entry {
215 Some((id, symbol, name)) => Ok(Some(
216 alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?,
217 )),
218 None => Ok(None),
219 }
220}
221
222async fn resolve_commodity_symbol(
229 user_id: Uuid,
230 symbol: &str,
231) -> wasmtime::Result<Option<CommodityEntry>> {
232 let result = ListCommodities::new().user_id(user_id).run().await;
233 let mut matches = list_commodity_entities("get-commodity", result)?
234 .into_iter()
235 .filter(|(_, sym, _)| {
236 sym.as_deref()
237 .is_some_and(|s| s.eq_ignore_ascii_case(symbol))
238 });
239 let first = matches.next();
240 if first.is_some() && matches.next().is_some() {
241 return Err(wasmtime::Error::msg(format!(
242 "get-commodity: symbol '{symbol}' is ambiguous (multiple commodities \
243 share it); reference it by uuid instead"
244 )));
245 }
246 Ok(first)
247}
248
249type CommodityEntry = (String, Option<String>, Option<String>);
254
255fn list_commodity_entities(
259 name: &str,
260 result: Result<Option<CmdResult>, CmdError>,
261) -> wasmtime::Result<Vec<CommodityEntry>> {
262 match result {
263 Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
264 .into_iter()
265 .filter_map(|(entity, tags)| match entity {
266 FinanceEntity::Commodity(c) => Some((
267 c.id.to_string(),
268 tag_value(&tags, "symbol").map(str::to_string),
269 tag_value(&tags, "name").map(str::to_string),
270 )),
271 _ => None,
272 })
273 .collect()),
274 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
275 "{name}: expected TaggedEntities, got {other:?}"
276 ))),
277 Ok(None) => Ok(Vec::new()),
278 Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
279 }
280}
281
282async fn alloc_commodity_entity(
286 caller: &mut Caller<'_, SessionData>,
287 id: &str,
288 symbol: Option<&str>,
289 name: Option<&str>,
290) -> wasmtime::Result<Rooted<StructRef>> {
291 let id_ref = alloc_string_ref(caller, id.as_bytes())?;
292 let symbol_ref = match symbol {
293 Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
294 None => None,
295 };
296 let name_ref = match name {
297 Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
298 None => None,
299 };
300 let args = [
301 Val::AnyRef(Some(id_ref.to_anyref())),
302 Val::AnyRef(symbol_ref.map(|r| r.to_anyref())),
303 Val::AnyRef(name_ref.map(|r| r.to_anyref())),
304 ];
305 alloc_entity_via_export(caller, "alloc_commodity_entity", &args).await
306}
307
308async fn alloc_commodity_chain(
312 caller: &mut Caller<'_, SessionData>,
313 entities: Vec<(String, Option<String>, Option<String>)>,
314) -> wasmtime::Result<Option<Rooted<StructRef>>> {
315 let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entities.len());
316 for (id, symbol, name) in entities {
317 let entity_ref =
318 alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?;
319 anyrefs.push(entity_ref.to_anyref());
320 }
321 alloc_pair_chain(caller, anyrefs).await
322}
323
324#[cfg(test)]
328fn format_tagged_commodities(
329 entities: &[(
330 FinanceEntity,
331 std::collections::HashMap<String, FinanceEntity>,
332 )],
333) -> String {
334 let mut out = String::from("(:commodities (");
335 for (idx, (entity, tags)) in entities.iter().enumerate() {
336 if idx > 0 {
337 out.push(' ');
338 }
339 let id = match entity {
340 FinanceEntity::Commodity(c) => c.id,
341 other => {
342 out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
343 continue;
344 }
345 };
346 out.push_str(&format!("(:id \"{id}\""));
347 if let Some(symbol) = tag_value(tags, "symbol") {
348 out.push_str(&format!(" :symbol {}", quote_string(symbol)));
349 }
350 if let Some(name) = tag_value(tags, "name") {
351 out.push_str(&format!(" :name {}", quote_string(name)));
352 }
353 out.push(')');
354 }
355 out.push_str("))");
356 out
357}
358
359fn tag_value<'a>(
360 tags: &'a std::collections::HashMap<String, FinanceEntity>,
361 key: &str,
362) -> Option<&'a str> {
363 tags.get(key).and_then(|t| match t {
364 FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
365 _ => None,
366 })
367}
368
369#[cfg(test)]
370fn quote_string(s: &str) -> String {
371 let mut q = String::with_capacity(s.len() + 2);
372 q.push('"');
373 for ch in s.chars() {
374 match ch {
375 '"' => q.push_str("\\\""),
376 '\\' => q.push_str("\\\\"),
377 other => q.push(other),
378 }
379 }
380 q.push('"');
381 q
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use finance::commodity::Commodity;
388 use std::collections::HashMap;
389 use uuid::Uuid;
390
391 fn commodity_entity(id: Uuid) -> FinanceEntity {
392 FinanceEntity::Commodity(Commodity { id })
393 }
394
395 #[test]
396 fn format_empty_list() {
397 assert_eq!(format_tagged_commodities(&[]), "(:commodities ())");
398 }
399
400 #[test]
401 fn format_single_commodity_with_symbol_and_name() {
402 let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
403 let mut tags = HashMap::new();
404 tags.insert(
405 "symbol".to_string(),
406 FinanceEntity::Tag(Tag {
407 id: Uuid::nil(),
408 tag_name: "symbol".into(),
409 tag_value: "USD".into(),
410 description: None,
411 }),
412 );
413 tags.insert(
414 "name".to_string(),
415 FinanceEntity::Tag(Tag {
416 id: Uuid::nil(),
417 tag_name: "name".into(),
418 tag_value: "US Dollar".into(),
419 description: None,
420 }),
421 );
422 let out = format_tagged_commodities(&[(commodity_entity(id), tags)]);
423 assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
424 assert!(out.contains(":symbol \"USD\""));
425 assert!(out.contains(":name \"US Dollar\""));
426 }
427
428 #[tokio::test]
429 async fn run_create_commodity_missing_symbol_emits_error() {
430 let err = run_create_commodity(Uuid::nil(), None, Some("name".into()))
431 .await
432 .unwrap_err();
433 assert!(err.to_string().contains(":symbol"));
434 }
435
436 #[tokio::test]
437 async fn run_create_commodity_missing_name_emits_error() {
438 let err = run_create_commodity(Uuid::nil(), Some("sym".into()), None)
439 .await
440 .unwrap_err();
441 assert!(err.to_string().contains(":name"));
442 }
443
444 #[test]
445 fn format_commodity_without_tags_emits_id_only() {
446 let id = Uuid::nil();
447 let out = format_tagged_commodities(&[(commodity_entity(id), HashMap::new())]);
448 assert_eq!(
449 out,
450 "(:commodities ((:id \"00000000-0000-0000-0000-000000000000\")))"
451 );
452 }
453}