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)]
11
use finance::tag::Tag;
12
#[cfg(test)]
13
use num_rational::Rational64;
14
use scripting::runtime::{
15
    alloc_entity_via_export, alloc_pair_chain, alloc_ratio_ref, alloc_string_ref, read_string_arg,
16
};
17
use server::command::split::{GetSplitTag, ListSplits, SetSplitTag};
18
use server::command::{CmdError, CmdResult, FinanceEntity};
19
use uuid::Uuid;
20
use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
21

            
22
use crate::session::SessionData;
23

            
24
pub const REGISTERED_COMMANDS: &[&str] = &[
25
    "list-splits",
26
    "list-splits-by-transaction",
27
    "set-split-tag",
28
    "get-split-tag",
29
];
30

            
31
2559
pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
32
2559
    register_readonly(linker)?;
33
2559
    register_mutators(linker)?;
34
2559
    Ok(())
35
2559
}
36

            
37
2660
pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
38
2660
    linker.func_wrap_async(
39
2660
        "nomi",
40
2660
        "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
18
        > {
46
18
            Box::new(async move {
47
18
                let user_id = caller.data().ctx().user_id;
48
18
                let id = read_string_arg(&mut caller, id_arg)?;
49
18
                let account_id = parse_account_id_arg(id)?;
50
18
                let result = ListSplits::new()
51
18
                    .user_id(user_id)
52
18
                    .account(account_id)
53
18
                    .run()
54
18
                    .await;
55
18
                let entries = list_split_entries("list-splits", result)?;
56
18
                alloc_split_chain(&mut caller, entries).await
57
18
            })
58
18
        },
59
    )?;
60
2660
    linker.func_wrap_async(
61
2660
        "nomi",
62
2660
        "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
108
        > {
68
108
            Box::new(async move {
69
108
                let user_id = caller.data().ctx().user_id;
70
108
                let id = read_string_arg(&mut caller, id_arg)?;
71
108
                let transaction_id = parse_transaction_id_arg(id)?;
72
108
                let result = ListSplits::new()
73
108
                    .user_id(user_id)
74
108
                    .transaction(transaction_id)
75
108
                    .run()
76
108
                    .await;
77
108
                let entries = list_split_entries("list-splits-by-transaction", result)?;
78
108
                alloc_split_chain(&mut caller, entries).await
79
108
            })
80
108
        },
81
    )?;
82
2660
    linker.func_wrap_async(
83
2660
        "nomi",
84
2660
        "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
108
        > {
90
108
            Box::new(async move {
91
108
                let user_id = caller.data().ctx().user_id;
92
108
                let id = read_string_arg(&mut caller, id_arg)?;
93
108
                let name = read_string_arg(&mut caller, name_arg)?;
94
108
                let value = run_get_split_tag(user_id, id, name).await?;
95
108
                Ok(Some(alloc_string_ref(&mut caller, value.as_bytes())?))
96
108
            })
97
108
        },
98
    )?;
99
2660
    Ok(())
100
2660
}
101

            
102
2559
pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
103
2559
    linker.func_wrap_async(
104
2559
        "nomi",
105
2559
        "split_set_split_tag",
106
        |mut caller: Caller<'_, SessionData>,
107
         (id_arg, name_arg, value_arg): super::StringArgTriple|
108
108
         -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
109
108
            Box::new(async move {
110
108
                let user_id = caller.data().ctx().user_id;
111
108
                let id = read_string_arg(&mut caller, id_arg)?;
112
108
                let name = read_string_arg(&mut caller, name_arg)?;
113
108
                let value = read_string_arg(&mut caller, value_arg)?;
114
108
                run_set_split_tag(user_id, id, name, value).await
115
108
            })
116
108
        },
117
    )?;
118
2559
    Ok(())
119
2559
}
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.
125
108
async fn run_set_split_tag(
126
108
    user_id: Uuid,
127
108
    id_arg: Option<String>,
128
108
    name_arg: Option<String>,
129
108
    value_arg: Option<String>,
130
108
) -> wasmtime::Result<i32> {
131
108
    let raw = id_arg
132
108
        .filter(|s| !s.is_empty())
133
108
        .ok_or_else(|| wasmtime::Error::msg("set-split-tag: missing or empty :split-id arg"))?;
134
108
    let split_id = Uuid::parse_str(&raw).map_err(|err| {
135
        wasmtime::Error::msg(format!("set-split-tag: invalid uuid '{raw}': {err}"))
136
    })?;
137
108
    let tag_name = name_arg
138
108
        .filter(|s| !s.is_empty())
139
108
        .ok_or_else(|| wasmtime::Error::msg("set-split-tag: missing or empty :tag-name arg"))?;
140
108
    let tag_value =
141
108
        value_arg.ok_or_else(|| wasmtime::Error::msg("set-split-tag: missing :tag-value arg"))?;
142
108
    SetSplitTag::new()
143
108
        .user_id(user_id)
144
108
        .split_id(split_id)
145
108
        .tag_name(tag_name)
146
108
        .tag_value(tag_value)
147
108
        .run()
148
108
        .await
149
108
        .map(|_| 1)
150
108
        .map_err(|err| wasmtime::Error::msg(format!("set-split-tag: {err}")))
151
108
}
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 "")`.
156
108
async fn run_get_split_tag(
157
108
    user_id: Uuid,
158
108
    id_arg: Option<String>,
159
108
    name_arg: Option<String>,
160
108
) -> wasmtime::Result<String> {
161
108
    let raw = id_arg
162
108
        .filter(|s| !s.is_empty())
163
108
        .ok_or_else(|| wasmtime::Error::msg("get-split-tag: missing or empty :split-id arg"))?;
164
108
    let split_id = Uuid::parse_str(&raw).map_err(|err| {
165
        wasmtime::Error::msg(format!("get-split-tag: invalid uuid '{raw}': {err}"))
166
    })?;
167
108
    let tag_name = name_arg
168
108
        .filter(|s| !s.is_empty())
169
108
        .ok_or_else(|| wasmtime::Error::msg("get-split-tag: missing or empty :tag-name arg"))?;
170
108
    match GetSplitTag::new()
171
108
        .user_id(user_id)
172
108
        .split_id(split_id)
173
108
        .tag_name(tag_name)
174
108
        .run()
175
108
        .await
176
    {
177
108
        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
108
}
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`.
188
20
fn parse_account_id_arg(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
189
20
    let raw = id_arg
190
20
        .filter(|s| !s.is_empty())
191
20
        .ok_or_else(|| wasmtime::Error::msg("list-splits: missing or empty :account-id arg"))?;
192
19
    Uuid::parse_str(&raw)
193
19
        .map_err(|err| wasmtime::Error::msg(format!("list-splits: invalid uuid '{raw}': {err}")))
194
20
}
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.
201
111
fn parse_transaction_id_arg(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
202
111
    let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
203
1
        wasmtime::Error::msg("list-splits-by-transaction: missing or empty :transaction-id arg")
204
1
    })?;
205
110
    Uuid::parse_str(&raw).map_err(|err| {
206
1
        wasmtime::Error::msg(format!(
207
            "list-splits-by-transaction: invalid uuid '{raw}': {err}"
208
        ))
209
1
    })
210
111
}
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.
215
type SplitEntry = (String, String, String, i64, i64);
216

            
217
126
fn list_split_entries(
218
126
    name: &str,
219
126
    result: Result<Option<CmdResult>, CmdError>,
220
126
) -> wasmtime::Result<Vec<SplitEntry>> {
221
126
    match result {
222
126
        Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
223
126
            .into_iter()
224
252
            .filter_map(|(entity, _)| match entity {
225
252
                FinanceEntity::Split(s) => Some((
226
252
                    s.id.to_string(),
227
252
                    s.account_id.to_string(),
228
252
                    s.commodity_id.to_string(),
229
252
                    s.value_num,
230
252
                    s.value_denom,
231
252
                )),
232
                _ => None,
233
252
            })
234
126
            .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
126
}
242

            
243
252
async fn alloc_split_entity(
244
252
    caller: &mut Caller<'_, SessionData>,
245
252
    id: &str,
246
252
    account_id: &str,
247
252
    commodity_id: &str,
248
252
    value_num: i64,
249
252
    value_denom: i64,
250
252
) -> wasmtime::Result<Rooted<StructRef>> {
251
252
    let id_ref = alloc_string_ref(caller, id.as_bytes())?;
252
252
    let account_ref = alloc_string_ref(caller, account_id.as_bytes())?;
253
252
    let commodity_ref = alloc_string_ref(caller, commodity_id.as_bytes())?;
254
252
    let ratio_ref = alloc_ratio_ref(caller, value_num, value_denom)?;
255
252
    let args = [
256
252
        Val::AnyRef(Some(id_ref.to_anyref())),
257
252
        Val::AnyRef(Some(account_ref.to_anyref())),
258
252
        Val::AnyRef(Some(commodity_ref.to_anyref())),
259
252
        Val::AnyRef(Some(ratio_ref.to_anyref())),
260
252
    ];
261
252
    alloc_entity_via_export(caller, "alloc_split", &args).await
262
252
}
263

            
264
126
async fn alloc_split_chain(
265
126
    caller: &mut Caller<'_, SessionData>,
266
126
    entries: Vec<(String, String, String, i64, i64)>,
267
126
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
268
126
    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
269
252
    for (id, account_id, commodity_id, num, denom) in entries {
270
252
        let entity_ref =
271
252
            alloc_split_entity(caller, &id, &account_id, &commodity_id, num, denom).await?;
272
252
        anyrefs.push(entity_ref.to_anyref());
273
    }
274
126
    alloc_pair_chain(caller, anyrefs).await
275
126
}
276

            
277
#[cfg(test)]
278
3
fn format_splits(
279
3
    entities: &[(
280
3
        FinanceEntity,
281
3
        std::collections::HashMap<String, FinanceEntity>,
282
3
    )],
283
3
) -> String {
284
3
    let mut out = String::from("(:splits (");
285
3
    for (idx, (entity, tags)) in entities.iter().enumerate() {
286
2
        if idx > 0 {
287
            out.push(' ');
288
2
        }
289
2
        match entity {
290
2
            FinanceEntity::Split(s) => {
291
2
                let value = format_rational(&Rational64::new(s.value_num, s.value_denom));
292
2
                let reconciled = match s.reconcile_state {
293
1
                    Some(true) => "t",
294
1
                    Some(false) | None => "nil",
295
                };
296
2
                out.push_str(&format!(
297
2
                    "(:id \"{}\" :transaction-id \"{}\" :account-id \"{}\" :commodity-id \"{}\" :value {} :reconciled {}",
298
2
                    s.id, s.tx_id, s.account_id, s.commodity_id, value, reconciled
299
2
                ));
300
2
                if let Some(note) = tag_value(tags, "note") {
301
1
                    out.push_str(&format!(" :note {}", quote_string(note)));
302
1
                }
303
2
                out.push(')');
304
            }
305
            other => {
306
                out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
307
            }
308
        }
309
    }
310
3
    out.push_str("))");
311
3
    out
312
3
}
313

            
314
#[cfg(test)]
315
2
fn format_rational(r: &Rational64) -> String {
316
2
    if *r.denom() == 1 {
317
1
        r.numer().to_string()
318
    } else {
319
1
        format!("{}/{}", r.numer(), r.denom())
320
    }
321
2
}
322

            
323
#[cfg(test)]
324
2
fn tag_value<'a>(
325
2
    tags: &'a std::collections::HashMap<String, FinanceEntity>,
326
2
    key: &str,
327
2
) -> Option<&'a str> {
328
2
    tags.get(key).and_then(|t| match t {
329
1
        FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
330
        _ => None,
331
1
    })
332
2
}
333

            
334
#[cfg(test)]
335
1
fn quote_string(s: &str) -> String {
336
1
    let mut q = String::with_capacity(s.len() + 2);
337
1
    q.push('"');
338
5
    for ch in s.chars() {
339
5
        match ch {
340
            '"' => q.push_str("\\\""),
341
            '\\' => q.push_str("\\\\"),
342
5
            other => q.push(other),
343
        }
344
    }
345
1
    q.push('"');
346
1
    q
347
1
}
348

            
349
#[cfg(test)]
350
mod tests {
351
    use super::*;
352
    use finance::split::Split;
353
    use std::collections::HashMap;
354

            
355
2
    fn split(value_num: i64, value_denom: i64, reconciled: Option<bool>) -> FinanceEntity {
356
2
        FinanceEntity::Split(Split {
357
2
            id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
358
2
            tx_id: Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(),
359
2
            account_id: Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap(),
360
2
            commodity_id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
361
2
            reconcile_state: reconciled,
362
2
            reconcile_date: None,
363
2
            value_num,
364
2
            value_denom,
365
2
            lot_id: None,
366
2
        })
367
2
    }
368

            
369
    #[test]
370
1
    fn format_empty_splits_list() {
371
1
        assert_eq!(format_splits(&[]), "(:splits ())");
372
1
    }
373

            
374
    #[test]
375
1
    fn format_single_split_no_tags_reconciled() {
376
1
        let out = format_splits(&[(split(1500, 100, Some(true)), HashMap::new())]);
377
1
        assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
378
1
        assert!(out.contains(":transaction-id \"11111111-1111-1111-1111-111111111111\""));
379
1
        assert!(out.contains(":account-id \"22222222-2222-2222-2222-222222222222\""));
380
1
        assert!(out.contains(":commodity-id \"33333333-3333-3333-3333-333333333333\""));
381
1
        assert!(out.contains(":value 15"));
382
1
        assert!(out.contains(":reconciled t"));
383
1
        assert!(!out.contains(":note"));
384
1
    }
385

            
386
    #[test]
387
1
    fn format_split_unreconciled_and_with_note_tag() {
388
1
        let mut tags = HashMap::new();
389
1
        tags.insert(
390
1
            "note".to_string(),
391
1
            FinanceEntity::Tag(Tag {
392
1
                id: Uuid::nil(),
393
1
                tag_name: "note".into(),
394
1
                tag_value: "lunch".into(),
395
1
                description: None,
396
1
            }),
397
        );
398
1
        let out = format_splits(&[(split(7, 3, None), tags)]);
399
1
        assert!(out.contains(":value 7/3"));
400
1
        assert!(out.contains(":reconciled nil"));
401
1
        assert!(out.contains(":note \"lunch\""));
402
1
    }
403

            
404
    #[test]
405
1
    fn parse_account_id_rejects_missing() {
406
1
        let err = parse_account_id_arg(None).unwrap_err();
407
1
        assert!(err.to_string().contains("missing or empty"), "got: {err}");
408
1
    }
409

            
410
    #[test]
411
1
    fn parse_account_id_rejects_invalid_uuid() {
412
1
        let err = parse_account_id_arg(Some("nope".into())).unwrap_err();
413
1
        assert!(err.to_string().contains("invalid uuid"), "got: {err}");
414
1
    }
415

            
416
    #[test]
417
1
    fn parse_transaction_id_rejects_missing() {
418
1
        let err = parse_transaction_id_arg(None).unwrap_err();
419
1
        assert!(
420
1
            err.to_string()
421
1
                .contains("list-splits-by-transaction: missing or empty"),
422
            "got: {err}"
423
        );
424
1
    }
425

            
426
    #[test]
427
1
    fn parse_transaction_id_rejects_invalid_uuid() {
428
1
        let err = parse_transaction_id_arg(Some("nope".into())).unwrap_err();
429
1
        assert!(
430
1
            err.to_string()
431
1
                .contains("list-splits-by-transaction: invalid uuid"),
432
            "got: {err}"
433
        );
434
1
    }
435

            
436
    #[test]
437
1
    fn parse_transaction_id_accepts_valid_uuid() {
438
1
        let uuid = "11111111-1111-1111-1111-111111111111";
439
1
        let parsed = parse_transaction_id_arg(Some(uuid.into())).unwrap();
440
1
        assert_eq!(parsed, Uuid::parse_str(uuid).unwrap());
441
1
    }
442
}