1#[cfg(test)]
11use finance::tag::Tag;
12#[cfg(test)]
13use num_rational::Rational64;
14use scripting::runtime::{
15 alloc_entity_via_export, alloc_pair_chain, alloc_ratio_ref, alloc_string_ref, read_string_arg,
16};
17use server::command::split::{GetSplitTag, ListSplits, SetSplitTag};
18use server::command::{CmdError, CmdResult, FinanceEntity};
19use uuid::Uuid;
20use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
21
22use crate::session::SessionData;
23
24pub const REGISTERED_COMMANDS: &[&str] = &[
25 "list-splits",
26 "list-splits-by-transaction",
27 "set-split-tag",
28 "get-split-tag",
29];
30
31pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
32 register_readonly(linker)?;
33 register_mutators(linker)?;
34 Ok(())
35}
36
37pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
38 linker.func_wrap_async(
39 "nomi",
40 "split_list_splits",
41 |mut caller: Caller<'_, SessionData>,
42 (id_arg,): (Option<Rooted<wasmtime::ArrayRef>>,)|
43 -> Box<
44 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
45 > {
46 Box::new(async move {
47 let user_id = caller.data().ctx().user_id;
48 let id = read_string_arg(&mut caller, id_arg)?;
49 let account_id = parse_account_id_arg(id)?;
50 let result = ListSplits::new()
51 .user_id(user_id)
52 .account(account_id)
53 .run()
54 .await;
55 let entries = list_split_entries("list-splits", result)?;
56 alloc_split_chain(&mut caller, entries).await
57 })
58 },
59 )?;
60 linker.func_wrap_async(
61 "nomi",
62 "split_list_splits_by_transaction",
63 |mut caller: Caller<'_, SessionData>,
64 (id_arg,): (Option<Rooted<wasmtime::ArrayRef>>,)|
65 -> Box<
66 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
67 > {
68 Box::new(async move {
69 let user_id = caller.data().ctx().user_id;
70 let id = read_string_arg(&mut caller, id_arg)?;
71 let transaction_id = parse_transaction_id_arg(id)?;
72 let result = ListSplits::new()
73 .user_id(user_id)
74 .transaction(transaction_id)
75 .run()
76 .await;
77 let entries = list_split_entries("list-splits-by-transaction", result)?;
78 alloc_split_chain(&mut caller, entries).await
79 })
80 },
81 )?;
82 linker.func_wrap_async(
83 "nomi",
84 "split_get_split_tag",
85 |mut caller: Caller<'_, SessionData>,
86 (id_arg, name_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
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 id = read_string_arg(&mut caller, id_arg)?;
93 let name = read_string_arg(&mut caller, name_arg)?;
94 let value = run_get_split_tag(user_id, id, name).await?;
95 Ok(Some(alloc_string_ref(&mut caller, value.as_bytes())?))
96 })
97 },
98 )?;
99 Ok(())
100}
101
102pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
103 linker.func_wrap_async(
104 "nomi",
105 "split_set_split_tag",
106 |mut caller: Caller<'_, SessionData>,
107 (id_arg, name_arg, value_arg): super::StringArgTriple|
108 -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
109 Box::new(async move {
110 let user_id = caller.data().ctx().user_id;
111 let id = read_string_arg(&mut caller, id_arg)?;
112 let name = read_string_arg(&mut caller, name_arg)?;
113 let value = read_string_arg(&mut caller, value_arg)?;
114 run_set_split_tag(user_id, id, name, value).await
115 })
116 },
117 )?;
118 Ok(())
119}
120
121async fn run_set_split_tag(
126 user_id: Uuid,
127 id_arg: Option<String>,
128 name_arg: Option<String>,
129 value_arg: Option<String>,
130) -> wasmtime::Result<i32> {
131 let raw = id_arg
132 .filter(|s| !s.is_empty())
133 .ok_or_else(|| wasmtime::Error::msg("set-split-tag: missing or empty :split-id arg"))?;
134 let split_id = Uuid::parse_str(&raw).map_err(|err| {
135 wasmtime::Error::msg(format!("set-split-tag: invalid uuid '{raw}': {err}"))
136 })?;
137 let tag_name = name_arg
138 .filter(|s| !s.is_empty())
139 .ok_or_else(|| wasmtime::Error::msg("set-split-tag: missing or empty :tag-name arg"))?;
140 let tag_value =
141 value_arg.ok_or_else(|| wasmtime::Error::msg("set-split-tag: missing :tag-value arg"))?;
142 SetSplitTag::new()
143 .user_id(user_id)
144 .split_id(split_id)
145 .tag_name(tag_name)
146 .tag_value(tag_value)
147 .run()
148 .await
149 .map(|_| 1)
150 .map_err(|err| wasmtime::Error::msg(format!("set-split-tag: {err}")))
151}
152
153async fn run_get_split_tag(
157 user_id: Uuid,
158 id_arg: Option<String>,
159 name_arg: Option<String>,
160) -> wasmtime::Result<String> {
161 let raw = id_arg
162 .filter(|s| !s.is_empty())
163 .ok_or_else(|| wasmtime::Error::msg("get-split-tag: missing or empty :split-id arg"))?;
164 let split_id = Uuid::parse_str(&raw).map_err(|err| {
165 wasmtime::Error::msg(format!("get-split-tag: invalid uuid '{raw}': {err}"))
166 })?;
167 let tag_name = name_arg
168 .filter(|s| !s.is_empty())
169 .ok_or_else(|| wasmtime::Error::msg("get-split-tag: missing or empty :tag-name arg"))?;
170 match GetSplitTag::new()
171 .user_id(user_id)
172 .split_id(split_id)
173 .tag_name(tag_name)
174 .run()
175 .await
176 {
177 Ok(Some(CmdResult::String(s))) => Ok(s),
178 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
179 "get-split-tag: expected String, got {other:?}"
180 ))),
181 Ok(None) => Ok(String::new()),
182 Err(err) => Err(wasmtime::Error::msg(format!("get-split-tag: {err}"))),
183 }
184}
185
186fn parse_account_id_arg(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
189 let raw = id_arg
190 .filter(|s| !s.is_empty())
191 .ok_or_else(|| wasmtime::Error::msg("list-splits: missing or empty :account-id arg"))?;
192 Uuid::parse_str(&raw)
193 .map_err(|err| wasmtime::Error::msg(format!("list-splits: invalid uuid '{raw}': {err}")))
194}
195
196fn parse_transaction_id_arg(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
202 let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
203 wasmtime::Error::msg("list-splits-by-transaction: missing or empty :transaction-id arg")
204 })?;
205 Uuid::parse_str(&raw).map_err(|err| {
206 wasmtime::Error::msg(format!(
207 "list-splits-by-transaction: invalid uuid '{raw}': {err}"
208 ))
209 })
210}
211
212type SplitEntry = (String, String, String, i64, i64);
216
217fn list_split_entries(
218 name: &str,
219 result: Result<Option<CmdResult>, CmdError>,
220) -> wasmtime::Result<Vec<SplitEntry>> {
221 match result {
222 Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
223 .into_iter()
224 .filter_map(|(entity, _)| match entity {
225 FinanceEntity::Split(s) => Some((
226 s.id.to_string(),
227 s.account_id.to_string(),
228 s.commodity_id.to_string(),
229 s.value_num,
230 s.value_denom,
231 )),
232 _ => None,
233 })
234 .collect()),
235 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
236 "{name}: expected TaggedEntities, got {other:?}"
237 ))),
238 Ok(None) => Ok(Vec::new()),
239 Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
240 }
241}
242
243async fn alloc_split_entity(
244 caller: &mut Caller<'_, SessionData>,
245 id: &str,
246 account_id: &str,
247 commodity_id: &str,
248 value_num: i64,
249 value_denom: i64,
250) -> wasmtime::Result<Rooted<StructRef>> {
251 let id_ref = alloc_string_ref(caller, id.as_bytes())?;
252 let account_ref = alloc_string_ref(caller, account_id.as_bytes())?;
253 let commodity_ref = alloc_string_ref(caller, commodity_id.as_bytes())?;
254 let ratio_ref = alloc_ratio_ref(caller, value_num, value_denom)?;
255 let args = [
256 Val::AnyRef(Some(id_ref.to_anyref())),
257 Val::AnyRef(Some(account_ref.to_anyref())),
258 Val::AnyRef(Some(commodity_ref.to_anyref())),
259 Val::AnyRef(Some(ratio_ref.to_anyref())),
260 ];
261 alloc_entity_via_export(caller, "alloc_split", &args).await
262}
263
264async fn alloc_split_chain(
265 caller: &mut Caller<'_, SessionData>,
266 entries: Vec<(String, String, String, i64, i64)>,
267) -> wasmtime::Result<Option<Rooted<StructRef>>> {
268 let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
269 for (id, account_id, commodity_id, num, denom) in entries {
270 let entity_ref =
271 alloc_split_entity(caller, &id, &account_id, &commodity_id, num, denom).await?;
272 anyrefs.push(entity_ref.to_anyref());
273 }
274 alloc_pair_chain(caller, anyrefs).await
275}
276
277#[cfg(test)]
278fn format_splits(
279 entities: &[(
280 FinanceEntity,
281 std::collections::HashMap<String, FinanceEntity>,
282 )],
283) -> String {
284 let mut out = String::from("(:splits (");
285 for (idx, (entity, tags)) in entities.iter().enumerate() {
286 if idx > 0 {
287 out.push(' ');
288 }
289 match entity {
290 FinanceEntity::Split(s) => {
291 let value = format_rational(&Rational64::new(s.value_num, s.value_denom));
292 let reconciled = match s.reconcile_state {
293 Some(true) => "t",
294 Some(false) | None => "nil",
295 };
296 out.push_str(&format!(
297 "(:id \"{}\" :transaction-id \"{}\" :account-id \"{}\" :commodity-id \"{}\" :value {} :reconciled {}",
298 s.id, s.tx_id, s.account_id, s.commodity_id, value, reconciled
299 ));
300 if let Some(note) = tag_value(tags, "note") {
301 out.push_str(&format!(" :note {}", quote_string(note)));
302 }
303 out.push(')');
304 }
305 other => {
306 out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
307 }
308 }
309 }
310 out.push_str("))");
311 out
312}
313
314#[cfg(test)]
315fn format_rational(r: &Rational64) -> String {
316 if *r.denom() == 1 {
317 r.numer().to_string()
318 } else {
319 format!("{}/{}", r.numer(), r.denom())
320 }
321}
322
323#[cfg(test)]
324fn tag_value<'a>(
325 tags: &'a std::collections::HashMap<String, FinanceEntity>,
326 key: &str,
327) -> Option<&'a str> {
328 tags.get(key).and_then(|t| match t {
329 FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
330 _ => None,
331 })
332}
333
334#[cfg(test)]
335fn quote_string(s: &str) -> String {
336 let mut q = String::with_capacity(s.len() + 2);
337 q.push('"');
338 for ch in s.chars() {
339 match ch {
340 '"' => q.push_str("\\\""),
341 '\\' => q.push_str("\\\\"),
342 other => q.push(other),
343 }
344 }
345 q.push('"');
346 q
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use finance::split::Split;
353 use std::collections::HashMap;
354
355 fn split(value_num: i64, value_denom: i64, reconciled: Option<bool>) -> FinanceEntity {
356 FinanceEntity::Split(Split {
357 id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
358 tx_id: Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(),
359 account_id: Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap(),
360 commodity_id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
361 reconcile_state: reconciled,
362 reconcile_date: None,
363 value_num,
364 value_denom,
365 lot_id: None,
366 })
367 }
368
369 #[test]
370 fn format_empty_splits_list() {
371 assert_eq!(format_splits(&[]), "(:splits ())");
372 }
373
374 #[test]
375 fn format_single_split_no_tags_reconciled() {
376 let out = format_splits(&[(split(1500, 100, Some(true)), HashMap::new())]);
377 assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
378 assert!(out.contains(":transaction-id \"11111111-1111-1111-1111-111111111111\""));
379 assert!(out.contains(":account-id \"22222222-2222-2222-2222-222222222222\""));
380 assert!(out.contains(":commodity-id \"33333333-3333-3333-3333-333333333333\""));
381 assert!(out.contains(":value 15"));
382 assert!(out.contains(":reconciled t"));
383 assert!(!out.contains(":note"));
384 }
385
386 #[test]
387 fn format_split_unreconciled_and_with_note_tag() {
388 let mut tags = HashMap::new();
389 tags.insert(
390 "note".to_string(),
391 FinanceEntity::Tag(Tag {
392 id: Uuid::nil(),
393 tag_name: "note".into(),
394 tag_value: "lunch".into(),
395 description: None,
396 }),
397 );
398 let out = format_splits(&[(split(7, 3, None), tags)]);
399 assert!(out.contains(":value 7/3"));
400 assert!(out.contains(":reconciled nil"));
401 assert!(out.contains(":note \"lunch\""));
402 }
403
404 #[test]
405 fn parse_account_id_rejects_missing() {
406 let err = parse_account_id_arg(None).unwrap_err();
407 assert!(err.to_string().contains("missing or empty"), "got: {err}");
408 }
409
410 #[test]
411 fn parse_account_id_rejects_invalid_uuid() {
412 let err = parse_account_id_arg(Some("nope".into())).unwrap_err();
413 assert!(err.to_string().contains("invalid uuid"), "got: {err}");
414 }
415
416 #[test]
417 fn parse_transaction_id_rejects_missing() {
418 let err = parse_transaction_id_arg(None).unwrap_err();
419 assert!(
420 err.to_string()
421 .contains("list-splits-by-transaction: missing or empty"),
422 "got: {err}"
423 );
424 }
425
426 #[test]
427 fn parse_transaction_id_rejects_invalid_uuid() {
428 let err = parse_transaction_id_arg(Some("nope".into())).unwrap_err();
429 assert!(
430 err.to_string()
431 .contains("list-splits-by-transaction: invalid uuid"),
432 "got: {err}"
433 );
434 }
435
436 #[test]
437 fn parse_transaction_id_accepts_valid_uuid() {
438 let uuid = "11111111-1111-1111-1111-111111111111";
439 let parsed = parse_transaction_id_arg(Some(uuid.into())).unwrap();
440 assert_eq!(parsed, Uuid::parse_str(uuid).unwrap());
441 }
442}