Skip to main content

rpc/natives/
split.rs

1//! Split-domain natives. Wraps `server::command::ListSplits`.
2//!
3//! `list-splits` returns a typed `pair<split>` of `$split` entity refs;
4//! each carries id, account-id, commodity-id, and value (Ratio). The
5//! `value_denom` ride alongside via the Ratio field. Notes / reconcile
6//! state stay reachable from the underlying `Tag` rows but are not
7//! surfaced on the typed entity yet — extend the `$split` shape +
8//! `nomi_entity!` declaration to expose more fields.
9
10#[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
121/// `set-split-tag` upsert: either a fresh `(split, tag_name, tag_value)`
122/// link or replace the existing value for that name. Returns 1 on
123/// success; the i32 return matches `set-account-tag`'s shape so a
124/// future error-cell migration only has to update one wire format.
125async 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
153/// `get-split-tag` lookup. Returns the empty string when the tag isn't
154/// set on this split — keeps the wasm contract simple (`string` return,
155/// no `Option` boxing). Scripts test absence with `(equal? v "")`.
156async 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
186/// Validates the :account-id arg into a UUID. Extracted so the validation
187/// contract is reachable from unit tests without a wasmtime `Caller`.
188fn 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
196/// Validates the :transaction-id arg into a UUID. Mirror of
197/// [`parse_account_id_arg`] for the transaction-keyed splits query — the
198/// server's `ListSplits` runs the by-transaction SQL when given a
199/// `.transaction()` filter, but `list-splits` only ever wired the account
200/// filter; this is the validation seam for the transaction variant.
201fn 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
212/// (id, account_id, commodity_id, value_num, value_denom) per split,
213/// flattened from `TaggedEntities` for the wasm allocator loop. Splits
214/// don't carry tags on the wire (yet), so the tuple omits the tag map.
215type 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}