Skip to main content

rpc/
draft.rs

1//! Transaction-draft accumulator for render-only template execution.
2//!
3//! A template is per-user nomiscript *source* that, when rendered, calls the
4//! draft natives (`set-draft-note`, `set-draft-date`, `draft-split`,
5//! `draft-tag`) to describe a not-yet-persisted transaction. Those natives
6//! mutate a [`TransactionDraft`] held in the session's Store data; the render
7//! entry point reads it back via `store.into_data()` after the program runs.
8//! Nothing here touches the database — the draft is pure intent the web/CLI
9//! layer turns into a pre-filled new-transaction form.
10
11/// One split line a template requested: an account + commodity (both by uuid
12/// string, resolved by the template via `get-account`/`get-commodity`), a
13/// rational amount, and any per-split tags the template attached via
14/// `draft-split-tag`.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct DraftSplit {
17    pub account_id: String,
18    pub commodity_id: String,
19    pub value_num: i64,
20    pub value_denom: i64,
21    pub tags: Vec<DraftTag>,
22}
23
24/// A `(name, value)` tag a template asked to attach to the drafted transaction.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct DraftTag {
27    pub name: String,
28    pub value: String,
29}
30
31/// The accumulated result of rendering a template: everything needed to
32/// pre-fill a new-transaction form. All fields are optional/append-only; a
33/// template sets what it knows and leaves the rest for the user.
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct TransactionDraft {
36    pub note: Option<String>,
37    pub date: Option<String>,
38    pub splits: Vec<DraftSplit>,
39    pub tags: Vec<DraftTag>,
40}
41
42impl TransactionDraft {
43    #[must_use]
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    pub fn set_note(&mut self, note: String) {
49        self.note = Some(note);
50    }
51
52    pub fn set_date(&mut self, date: String) {
53        self.date = Some(date);
54    }
55
56    /// The number of splits drafted so far — the index the next
57    /// [`add_split`](Self::add_split) will return.
58    #[must_use]
59    pub fn split_count(&self) -> usize {
60        self.splits.len()
61    }
62
63    /// Appends a split and returns its index — the handle a template passes to
64    /// [`add_split_tag`](Self::add_split_tag) to tag that specific split.
65    pub fn add_split(&mut self, split: DraftSplit) -> usize {
66        self.splits.push(split);
67        self.splits.len() - 1
68    }
69
70    pub fn add_tag(&mut self, tag: DraftTag) {
71        self.tags.push(tag);
72    }
73
74    /// Attaches a tag to the split at `index`. Returns `false` if the index is
75    /// out of range (a template handed a stale/invalid handle).
76    pub fn add_split_tag(&mut self, index: usize, tag: DraftTag) -> bool {
77        match self.splits.get_mut(index) {
78            Some(split) => {
79                split.tags.push(tag);
80                true
81            }
82            None => false,
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    fn split() -> DraftSplit {
92        DraftSplit {
93            account_id: "a".to_string(),
94            commodity_id: "c".to_string(),
95            value_num: 1,
96            value_denom: 1,
97            tags: Vec::new(),
98        }
99    }
100
101    #[test]
102    fn split_count_predicts_next_add_split_index() {
103        let mut draft = TransactionDraft::new();
104        assert_eq!(draft.split_count(), 0);
105        assert_eq!(draft.add_split(split()), 0);
106        assert_eq!(draft.split_count(), 1);
107        assert_eq!(draft.add_split(split()), 1);
108        assert_eq!(draft.split_count(), 2);
109    }
110
111    #[test]
112    fn add_split_tag_targets_only_the_indexed_split() {
113        let mut draft = TransactionDraft::new();
114        let first = draft.add_split(split());
115        let second = draft.add_split(split());
116
117        assert!(draft.add_split_tag(
118            first,
119            DraftTag {
120                name: "memo".to_string(),
121                value: "lunch".to_string(),
122            }
123        ));
124
125        assert_eq!(draft.splits[first].tags.len(), 1);
126        assert_eq!(draft.splits[first].tags[0].name, "memo");
127        assert!(draft.splits[second].tags.is_empty());
128    }
129
130    #[test]
131    fn add_split_tag_rejects_out_of_range_handle() {
132        let mut draft = TransactionDraft::new();
133        draft.add_split(split());
134        assert!(!draft.add_split_tag(
135            7,
136            DraftTag {
137                name: "k".to_string(),
138                value: "v".to_string(),
139            }
140        ));
141    }
142}