Commit0169200bRecorded25 Mar 2026Repositorysigil-ledger

Implement sigil-ledger library for hledger journal files

Message

Core (sigil ledger) module provides: - Record types for transactions, postings, amounts, and costs - Journal parser handling dates, status flags, codes, descriptions, amounts with commodities, cost notation (@/@@), balance assertions, inline comments, and tags - Journal writer producing valid hledger format - Transaction deduplication by reference ID tag - File read/write/append operations

Report module (sigil ledger report) wraps hledger CLI for: - Balance, register, and income statement reports - JSON output parsing via hledger print - Flexible query and option passing

Includes 29 tests covering formatting, parsing, round-tripping, cost notation, balance assertions, tags, and deduplication.

Changed
 .gitignore                  |   2 +
 package.sgl                 |   3 +-
 src/sigil/ledger.sgl        | 675 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/sigil/ledger/report.sgl | 176 +++++++++++++++++++++++++++++++++++++++++++
 test/test-ledger.sgl        | 387 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 1242 insertions(+), 1 deletion(-)
Diff
.gitignoreadded
@@ -0,0 +1,2 @@
+1
build/
+2
.mcp.json
package.sglmodified
@@ -28,7 +28,8 @@
28
dependencies: (list
29
(from-git url: sigil-repo package: "sigil-stdlib")
30
(from-git url: sigil-repo package: "sigil-json")
31
(from-git url: sigil-repo package: "sigil-log"))
+31
(from-git url: sigil-repo package: "sigil-log")
+32
(from-git url: sigil-repo package: "sigil-test"))
33
34
tasks: (list
35
(task
src/sigil/ledger.sgladded
@@ -0,0 +1,675 @@
+1
;;; (sigil ledger) - hledger Journal File Reader/Writer
+2
;;;
+3
;;; Read and write hledger journal files. Parses transactions, postings,
+4
;;; amounts with commodities, tags, and cost notation. Supports appending
+5
;;; new transactions and deduplicating by reference ID.
+6
;;;
+7
;;; ## Basic Usage
+8
;;;
+9
;;; ```scheme
+10
;;; (import (sigil ledger))
+11
;;;
+12
;;; ;; Read a journal file
+13
;;; (define txns (read-journal "main.journal"))
+14
;;;
+15
;;; ;; Write transactions to a file
+16
;;; (write-journal "output.journal" txns)
+17
;;;
+18
;;; ;; Append new transactions (common case for imports)
+19
;;; (append-transactions "main.journal" new-txns)
+20
;;;
+21
;;; ;; Deduplicate by reference ID tag
+22
;;; (define unique (deduplicate-transactions new-txns existing-txns))
+23
;;; ```
+24
+25
(define-library (sigil ledger)
+26
(import (sigil string)
+27
(sigil io)
+28
(sigil fs)
+29
(sigil struct))
+30
(export
+31
;; Records
+32
journal-amount
+33
journal-amount?
+34
journal-amount-quantity
+35
journal-amount-commodity
+36
+37
journal-cost
+38
journal-cost?
+39
journal-cost-type
+40
journal-cost-amount
+41
+42
journal-posting
+43
journal-posting?
+44
journal-posting-account
+45
journal-posting-amount
+46
journal-posting-cost
+47
journal-posting-comment
+48
journal-posting-tags
+49
journal-posting-balance-assertion
+50
+51
journal-transaction
+52
journal-transaction?
+53
journal-transaction-date
+54
journal-transaction-status
+55
journal-transaction-code
+56
journal-transaction-description
+57
journal-transaction-comment
+58
journal-transaction-tags
+59
journal-transaction-postings
+60
+61
;; Parsing
+62
read-journal
+63
parse-journal
+64
+65
;; Writing
+66
write-journal
+67
format-transaction
+68
format-transactions
+69
+70
;; Appending & deduplication
+71
append-transactions
+72
deduplicate-transactions)
+73
+74
(begin
+75
+76
;; ========== Records ==========
+77
+78
(define-struct journal-amount
+79
(quantity default: 0)
+80
(commodity default: ""))
+81
+82
(define-struct journal-cost
+83
(type default: 'per-unit) ; 'per-unit (@) or 'total (@@)
+84
(amount default: #f)) ; journal-amount
+85
+86
(define-struct journal-posting
+87
(account default: "")
+88
(amount default: #f) ; journal-amount or #f (inferred)
+89
(cost default: #f) ; journal-cost or #f
+90
(comment default: "")
+91
(tags default: '()) ; alist of (name . value)
+92
(balance-assertion default: #f)) ; journal-amount or #f
+93
+94
(define-struct journal-transaction
+95
(date default: "")
+96
(status default: "") ; "" (unmarked), "!" (pending), "*" (cleared)
+97
(code default: "") ; optional code in parentheses
+98
(description default: "")
+99
(comment default: "")
+100
(tags default: '()) ; alist of (name . value)
+101
(postings default: '())) ; list of journal-posting
+102
+103
;; ========== Journal Writing ==========
+104
+105
;;; Format a journal-amount as a string.
+106
;;;
+107
;;; Handles both left-symbol (e.g. "$100.00") and right-symbol
+108
;;; (e.g. "100.00 EUR") commodities. Symbols containing only
+109
;;; common currency characters are placed adjacent to the number;
+110
;;; alphabetic commodity codes are placed after with a space.
+111
(define (format-amount amt)
+112
(if (not amt)
+113
""
+114
(let ((qty (journal-amount-quantity amt))
+115
(comm (journal-amount-commodity amt)))
+116
(if (string-empty? comm)
+117
(format-number qty)
+118
(if (currency-symbol? comm)
+119
(str comm (format-number qty))
+120
(str (format-number qty) " " comm))))))
+121
+122
;;; Check if a commodity string is a currency symbol (like $, EUR, etc.)
+123
;;; Currency symbols go before the number, alpha codes go after.
+124
(define (currency-symbol? s)
+125
(and (not (string-empty? s))
+126
(let ((c (string-ref s 0)))
+127
(or (char=? c #\$)
+128
(char=? c (integer->char 163)) ; pound
+129
(char=? c (integer->char 165)) ; yen
+130
(char=? c (integer->char 8364)) ; euro sign
+131
))))
+132
+133
;;; Format a number for journal output.
+134
;;; Ensures at least 2 decimal places for currency amounts.
+135
(define (format-number n)
+136
(if (integer? n)
+137
(str (number->string n) ".00")
+138
(let ((s (number->string n)))
+139
;; Ensure at least 2 decimal places
+140
(let ((dot-pos (string-find s ".")))
+141
(if dot-pos
+142
(let ((decimals (- (string-length s) dot-pos 1)))
+143
(if (< decimals 2)
+144
(str s (string-repeat "0" (- 2 decimals)))
+145
s))
+146
(str s ".00"))))))
+147
+148
;;; Format a cost notation string.
+149
(define (format-cost cost)
+150
(if (not cost)
+151
""
+152
(let ((type (journal-cost-type cost))
+153
(amt (journal-cost-amount cost)))
+154
(if (eq? type 'total)
+155
(str " @@ " (format-amount amt))
+156
(str " @ " (format-amount amt))))))
+157
+158
;;; Format tags as a comment string fragment.
+159
;;; Returns a string like "; tag1:val1, tag2:val2" or "" if no tags.
+160
(define (format-tags tags)
+161
(if (or (not tags) (null? tags))
+162
""
+163
(string-join (map (lambda (tag)
+164
(if (string-empty? (cdr tag))
+165
(str (car tag) ":")
+166
(str (car tag) ": " (cdr tag))))
+167
tags)
+168
", ")))
+169
+170
;;; Format a single posting as a journal line.
+171
(define (format-posting posting)
+172
(let* ((account (journal-posting-account posting))
+173
(amt (journal-posting-amount posting))
+174
(cost (journal-posting-cost posting))
+175
(comment (journal-posting-comment posting))
+176
(tags (journal-posting-tags posting))
+177
(bal (journal-posting-balance-assertion posting))
+178
(amount-str (if amt (format-amount amt) ""))
+179
(cost-str (format-cost cost))
+180
(bal-str (if bal
+181
(str " = " (format-amount bal))
+182
""))
+183
;; Combine tags and comment
+184
(tag-str (format-tags tags))
+185
(comment-parts (cond
+186
((and (not (string-empty? comment))
+187
(not (string-empty? tag-str)))
+188
(str " ; " comment ", " tag-str))
+189
((not (string-empty? comment))
+190
(str " ; " comment))
+191
((not (string-empty? tag-str))
+192
(str " ; " tag-str))
+193
(else ""))))
+194
(if (string-empty? amount-str)
+195
(str " " account comment-parts)
+196
(str " " account " " amount-str cost-str bal-str comment-parts))))
+197
+198
;;; Format a single transaction as a journal string.
+199
;;;
+200
;;; ```scheme
+201
;;; (format-transaction
+202
;;; (journal-transaction
+203
;;; date: "2026-03-15"
+204
;;; status: "*"
+205
;;; description: "Grocery store"
+206
;;; postings: (list
+207
;;; (journal-posting account: "expenses:food" amount: (journal-amount quantity: 47.23 commodity: "USD"))
+208
;;; (journal-posting account: "assets:checking"))))
+209
;;; ```
+210
(define (format-transaction txn)
+211
(let* ((date (journal-transaction-date txn))
+212
(status (journal-transaction-status txn))
+213
(code (journal-transaction-code txn))
+214
(desc (journal-transaction-description txn))
+215
(comment (journal-transaction-comment txn))
+216
(tags (journal-transaction-tags txn))
+217
(postings (journal-transaction-postings txn))
+218
;; Build header line
+219
(header (str date
+220
(if (string-empty? status) "" (str " " status))
+221
(if (string-empty? code) "" (str " (" code ")"))
+222
(if (string-empty? desc) "" (str " " desc))))
+223
;; Transaction-level comment/tags
+224
(tag-str (format-tags tags))
+225
(txn-comment (cond
+226
((and (not (string-empty? comment))
+227
(not (string-empty? tag-str)))
+228
(str "\n ; " comment ", " tag-str))
+229
((not (string-empty? comment))
+230
(str "\n ; " comment))
+231
((not (string-empty? tag-str))
+232
(str "\n ; " tag-str))
+233
(else "")))
+234
;; Format postings
+235
(posting-lines (map format-posting postings)))
+236
(string-join (cons (str header txn-comment) posting-lines) "\n")))
+237
+238
;;; Format a list of transactions as a complete journal string.
+239
(define (format-transactions txns)
+240
(string-join (map format-transaction txns) "\n\n"))
+241
+242
;;; Write a list of transactions to a journal file.
+243
;;;
+244
;;; Creates or overwrites the file with valid hledger journal format.
+245
(define (write-journal path txns)
+246
(write-file-string path (str (format-transactions txns) "\n")))
+247
+248
;;; Append transactions to an existing journal file.
+249
;;;
+250
;;; Adds new transactions to the end of the file, separated by
+251
;;; blank lines. Creates the file if it doesn't exist.
+252
(define (append-transactions path txns)
+253
(if (null? txns)
+254
#t
+255
(let ((new-content (format-transactions txns)))
+256
(guard (exn (else (write-file-string path (str new-content "\n"))))
+257
(let ((existing (read-file-string path)))
+258
(write-file-string path
+259
(str (string-trim-end existing) "\n\n" new-content "\n")))))))
+260
+261
;; ========== Journal Parsing ==========
+262
+263
;;; Read and parse an hledger journal file.
+264
;;;
+265
;;; Returns a list of journal-transaction records.
+266
;;;
+267
;;; ```scheme
+268
;;; (define txns (read-journal "main.journal"))
+269
;;; (journal-transaction-date (car txns)) ; => "2026-03-15"
+270
;;; ```
+271
(define (read-journal path)
+272
(parse-journal (read-file-string path)))
+273
+274
;;; Parse a journal string into a list of transactions.
+275
;;;
+276
;;; Handles dates, status flags, descriptions, postings with
+277
;;; amounts and commodities, comments, tags, cost notation,
+278
;;; and balance assertions.
+279
(define (parse-journal text)
+280
(let ((lines (string-split text "\n")))
+281
(parse-lines lines '())))
+282
+283
;; Parse lines into transactions, accumulating results
+284
(define (parse-lines lines acc)
+285
(if (null? lines)
+286
(reverse acc)
+287
(let ((line (car lines)))
+288
(if (transaction-start? line)
+289
;; Found a transaction header — gather its lines
+290
(let ((result (gather-transaction-lines (cdr lines) '())))
+291
(let ((posting-lines (car result))
+292
(remaining (cdr result)))
+293
(let ((txn (parse-transaction line posting-lines)))
+294
(parse-lines remaining (cons txn acc)))))
+295
;; Skip non-transaction lines (comments, directives, blank)
+296
(parse-lines (cdr lines) acc)))))
+297
+298
;; Check if a line starts a transaction (begins with a date)
+299
(define (transaction-start? line)
+300
(and (>= (string-length line) 10)
+301
(char-numeric? (string-ref line 0))
+302
(char-numeric? (string-ref line 1))
+303
(char-numeric? (string-ref line 2))
+304
(char-numeric? (string-ref line 3))
+305
(date-separator? (string-ref line 4))
+306
(char-numeric? (string-ref line 5))
+307
(char-numeric? (string-ref line 6))
+308
(date-separator? (string-ref line 7))
+309
(char-numeric? (string-ref line 8))
+310
(char-numeric? (string-ref line 9))))
+311
+312
(define (date-separator? c)
+313
(or (char=? c #\-) (char=? c #\/) (char=? c #\.)))
+314
+315
;; Gather continuation lines (indented or blank) for a transaction
+316
(define (gather-transaction-lines lines acc)
+317
(if (null? lines)
+318
(cons (reverse acc) '())
+319
(let* ((line (car lines))
+320
(trimmed (string-trim line)))
+321
(if (or (string-empty? trimmed)
+322
(and (> (string-length line) 0)
+323
(or (char=? (string-ref line 0) #\space)
+324
(char=? (string-ref line 0) #\tab))))
+325
;; Skip blank lines within transaction, collect indented lines
+326
(if (string-empty? trimmed)
+327
;; Blank line — could be end of transaction, peek ahead
+328
(if (and (pair? (cdr lines))
+329
(> (string-length (cadr lines)) 0)
+330
(or (char=? (string-ref (cadr lines) 0) #\space)
+331
(char=? (string-ref (cadr lines) 0) #\tab)))
+332
;; Next line is indented, continue
+333
(gather-transaction-lines (cdr lines) acc)
+334
;; End of transaction
+335
(cons (reverse acc) (cdr lines)))
+336
(gather-transaction-lines (cdr lines) (cons line acc)))
+337
;; Non-indented, non-blank line = new transaction or directive
+338
(cons (reverse acc) lines)))))
+339
+340
;; Parse a transaction from its header line and posting lines
+341
(define (parse-transaction header-line posting-lines)
+342
(let* ((header (parse-transaction-header header-line))
+343
(txn-comment-and-tags (extract-header-comments posting-lines))
+344
(txn-comment (car txn-comment-and-tags))
+345
(txn-tags (cadr txn-comment-and-tags))
+346
(real-posting-lines (caddr txn-comment-and-tags))
+347
(postings (map parse-posting real-posting-lines)))
+348
(journal-transaction
+349
date: (car header)
+350
status: (cadr header)
+351
code: (caddr header)
+352
description: (cadddr header)
+353
comment: txn-comment
+354
tags: txn-tags
+355
postings: postings)))
+356
+357
;; Extract transaction-level comments (indented ; lines before postings with accounts)
+358
(define (extract-header-comments lines)
+359
(let loop ((remaining lines) (comment "") (tags '()))
+360
(if (null? remaining)
+361
(list comment tags '())
+362
(let ((line (string-trim (car remaining))))
+363
(if (and (> (string-length line) 0)
+364
(char=? (string-ref line 0) #\;))
+365
;; Comment line
+366
(let* ((comment-text (string-trim (substring line 1 (string-length line))))
+367
(parsed-tags (parse-tags-from-comment comment-text))
+368
(new-comment (if (string-empty? comment)
+369
comment-text
+370
(str comment ", " comment-text)))
+371
(new-tags (append tags parsed-tags)))
+372
(loop (cdr remaining) new-comment new-tags))
+373
;; Not a comment, these are posting lines
+374
(list comment tags remaining))))))
+375
+376
;; Parse the transaction header line
+377
;; Format: DATE [STATUS] [(CODE)] DESCRIPTION
+378
(define (parse-transaction-header line)
+379
(let* ((date (substring line 0 10))
+380
(rest (string-trim (substring line 10 (string-length line))))
+381
;; Parse status
+382
(status-result (parse-status rest))
+383
(status (car status-result))
+384
(rest2 (cdr status-result))
+385
;; Parse code
+386
(code-result (parse-code rest2))
+387
(code (car code-result))
+388
(rest3 (cdr code-result))
+389
;; Remaining is description
+390
(description (string-trim rest3)))
+391
(list date status code description)))
+392
+393
;; Parse optional status flag (* or !)
+394
(define (parse-status text)
+395
(let ((s (string-trim text)))
+396
(if (string-empty? s)
+397
(cons "" s)
+398
(let ((c (string-ref s 0)))
+399
(cond
+400
((char=? c #\*)
+401
(cons "*" (string-trim (substring s 1 (string-length s)))))
+402
((char=? c #\!)
+403
(cons "!" (string-trim (substring s 1 (string-length s)))))
+404
(else
+405
(cons "" s)))))))
+406
+407
;; Parse optional code in parentheses
+408
(define (parse-code text)
+409
(let ((s (string-trim text)))
+410
(if (and (> (string-length s) 0) (char=? (string-ref s 0) #\())
+411
(let ((close (string-find s ")")))
+412
(if close
+413
(cons (substring s 1 close)
+414
(string-trim (substring s (+ close 1) (string-length s))))
+415
(cons "" s)))
+416
(cons "" s))))
+417
+418
;; Parse a posting line
+419
(define (parse-posting line)
+420
(let* ((trimmed (string-trim line))
+421
;; Split off inline comment
+422
(comment-split (split-inline-comment trimmed))
+423
(main-part (car comment-split))
+424
(comment-text (cdr comment-split))
+425
(posting-tags (if (string-empty? comment-text)
+426
'()
+427
(parse-tags-from-comment comment-text)))
+428
;; Parse the main part: account amount [cost] [= assertion]
+429
(parsed (parse-posting-parts main-part)))
+430
(journal-posting
+431
account: (car parsed)
+432
amount: (cadr parsed)
+433
cost: (caddr parsed)
+434
comment: comment-text
+435
tags: posting-tags
+436
balance-assertion: (cadddr parsed))))
+437
+438
;; Split a line at the first inline comment (;), respecting the
+439
;; hledger rule that ; must be preceded by 2+ spaces
+440
(define (split-inline-comment text)
+441
(let ((len (string-length text)))
+442
(let loop ((i 0))
+443
(if (>= i len)
+444
(cons text "")
+445
(if (and (char=? (string-ref text i) #\;)
+446
(>= i 2)
+447
(char=? (string-ref text (- i 1)) #\space)
+448
(char=? (string-ref text (- i 2)) #\space))
+449
(cons (string-trim-end (substring text 0 (- i 2)))
+450
(string-trim (substring text (+ i 1) len)))
+451
(loop (+ i 1)))))))
+452
+453
;; Parse tags from a comment string like "tag1:val1, tag2:val2"
+454
(define (parse-tags-from-comment text)
+455
(let ((parts (string-split text ",")))
+456
(let loop ((rest parts) (tags '()))
+457
(if (null? rest)
+458
(reverse tags)
+459
(let ((part (string-trim (car rest))))
+460
(let ((colon (string-find part ":")))
+461
(if colon
+462
(let ((name (string-trim (substring part 0 colon)))
+463
(value (string-trim (substring part (+ colon 1) (string-length part)))))
+464
(loop (cdr rest) (cons (cons name value) tags)))
+465
(loop (cdr rest) tags))))))))
+466
+467
;; Parse posting parts: account, amount, cost, balance assertion
+468
;; The account and amount are separated by 2+ spaces
+469
(define (parse-posting-parts text)
+470
(let ((trimmed (string-trim text)))
+471
;; Find the split point: 2+ consecutive spaces
+472
(let ((split-pos (find-double-space trimmed)))
+473
(if (not split-pos)
+474
;; No amount — just an account (amount inferred)
+475
(list trimmed #f #f #f)
+476
(let* ((account (string-trim (substring trimmed 0 split-pos)))
+477
(amount-part (string-trim (substring trimmed split-pos (string-length trimmed)))))
+478
;; Parse amount part which may include cost and balance assertion
+479
(parse-amount-cost-assertion account amount-part))))))
+480
+481
;; Find position of first occurrence of 2+ consecutive spaces
+482
(define (find-double-space text)
+483
(let ((len (string-length text)))
+484
(let loop ((i 0))
+485
(if (>= (+ i 1) len)
+486
#f
+487
(if (and (char=? (string-ref text i) #\space)
+488
(char=? (string-ref text (+ i 1)) #\space))
+489
i
+490
(loop (+ i 1)))))))
+491
+492
;; Parse amount, optional cost (@/@@), and optional balance assertion (=)
+493
(define (parse-amount-cost-assertion account text)
+494
(let* (;; Check for balance assertion first
+495
(assertion-split (split-balance-assertion text))
+496
(amount-cost-part (car assertion-split))
+497
(assertion (cdr assertion-split))
+498
;; Check for cost notation
+499
(cost-split (split-cost amount-cost-part))

Showing the first 500 of 676 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.

src/sigil/ledger/report.sgladded
@@ -0,0 +1,176 @@
+1
;;; (sigil ledger report) - hledger CLI Report Generation
+2
;;;
+3
;;; Run hledger CLI commands and parse their output for reports.
+4
;;; Uses `--output-format json` where available for structured data.
+5
;;;
+6
;;; ## Basic Usage
+7
;;;
+8
;;; ```scheme
+9
;;; (import (sigil ledger report))
+10
;;;
+11
;;; ;; Get account balances
+12
;;; (hledger-balance "main.journal" "assets")
+13
;;;
+14
;;; ;; Get transaction register
+15
;;; (hledger-register "main.journal" "expenses" begin: "2026-01")
+16
;;;
+17
;;; ;; Income statement
+18
;;; (hledger-income-statement "main.journal" period: "monthly")
+19
;;;
+20
;;; ;; Run any hledger command
+21
;;; (hledger-command "accounts" file: "main.journal")
+22
;;; ```
+23
+24
(define-library (sigil ledger report)
+25
(import (sigil string)
+26
(sigil io)
+27
(sigil process)
+28
(sigil json))
+29
(export
+30
hledger-available?
+31
hledger-command
+32
hledger-balance
+33
hledger-register
+34
hledger-income-statement
+35
hledger-print-json)
+36
+37
(begin
+38
+39
;;; Check if hledger is available on PATH.
+40
(define (hledger-available?)
+41
(command-exists? "hledger"))
+42
+43
(define (ensure-hledger!)
+44
(unless (hledger-available?)
+45
(error "hledger is not installed or not on PATH")))
+46
+47
;;; Run an arbitrary hledger command and return its output as a string.
+48
;;;
+49
;;; The `file:` keyword specifies the journal file.
+50
;;; Additional arguments are passed through to hledger.
+51
;;;
+52
;;; ```scheme
+53
;;; (hledger-command "accounts" file: "main.journal")
+54
;;; ; => "assets:checking\nassets:savings\n..."
+55
;;;
+56
;;; (hledger-command "bal" file: "main.journal" args: '("assets" "--tree"))
+57
;;; ; => balance report as text
+58
;;; ```
+59
(define (hledger-command command (keys: (file #f) (args '())))
+60
(ensure-hledger!)
+61
(let ((cmd-args (if file
+62
(cons command (cons "-f" (cons file args)))
+63
(cons command args))))
+64
(apply process-output->string "hledger" cmd-args)))
+65
+66
;; Internal: run a report command with parsed query/option args
+67
(define (run-report command file rest)
+68
(ensure-hledger!)
+69
(let* ((query-and-opts (parse-report-args rest))
+70
(query (car query-and-opts))
+71
(opts (cdr query-and-opts))
+72
(args (build-report-args command file query opts)))
+73
(apply process-output->string "hledger" args)))
+74
+75
;;; Run `hledger bal` and return the output.
+76
;;;
+77
;;; ```scheme
+78
;;; (hledger-balance "main.journal")
+79
;;; (hledger-balance "main.journal" "assets")
+80
;;; (hledger-balance "main.journal" "expenses"
+81
;;; begin: "2026-01" end: "2026-04" depth: 2 tree: #t)
+82
;;; ```
+83
(define (hledger-balance file . rest)
+84
(run-report "bal" file rest))
+85
+86
;;; Run `hledger reg` and return the output.
+87
;;;
+88
;;; ```scheme
+89
;;; (hledger-register "main.journal" "expenses:food")
+90
;;; (hledger-register "main.journal" begin: "2026-03")
+91
;;; ```
+92
(define (hledger-register file . rest)
+93
(run-report "reg" file rest))
+94
+95
;;; Run `hledger is` (income statement) and return the output.
+96
;;;
+97
;;; ```scheme
+98
;;; (hledger-income-statement "main.journal")
+99
;;; (hledger-income-statement "main.journal" period: "monthly")
+100
;;; ```
+101
(define (hledger-income-statement file . rest)
+102
(run-report "is" file rest))
+103
+104
;;; Run `hledger print -O json` and return parsed JSON.
+105
;;;
+106
;;; Returns the hledger JSON representation of transactions,
+107
;;; parsed into Sigil dicts/arrays.
+108
;;;
+109
;;; ```scheme
+110
;;; (define txns (hledger-print-json "main.journal"))
+111
;;; ```
+112
(define (hledger-print-json file . rest)
+113
(ensure-hledger!)
+114
(let* ((query-and-opts (parse-report-args rest))
+115
(query (car query-and-opts))
+116
(opts (cdr query-and-opts))
+117
(args (build-report-args "print" file query
+118
(cons (cons output-format: "json") opts))))
+119
(let ((output (apply process-output->string "hledger" args)))
+120
(if (string-empty? (string-trim output))
+121
#[]
+122
(json-decode output)))))
+123
+124
;; Parse variadic report arguments into (query . opts) pair
+125
(define (parse-report-args args)
+126
(let loop ((rest args) (query '()) (opts '()))
+127
(if (null? rest)
+128
(cons (reverse query) (reverse opts))
+129
(let ((arg (car rest)))
+130
(cond
+131
((keyword? arg)
+132
(if (null? (cdr rest))
+133
(cons (reverse query) (reverse opts))
+134
(loop (cddr rest) query
+135
(cons (cons arg (cadr rest)) opts))))
+136
((string? arg)
+137
(loop (cdr rest) (cons arg query) opts))
+138
(else
+139
(loop (cdr rest) query opts)))))))
+140
+141
;; Build hledger argument list from parsed report options
+142
(define (build-report-args command file query opts)
+143
(let ((base (list command "-f" file)))
+144
(let ((with-opts (fold-right
+145
(lambda (opt acc)
+146
(let ((key (car opt))
+147
(val (cdr opt)))
+148
(cond
+149
((eq? key begin:)
+150
(cons "-b" (cons val acc)))
+151
((eq? key end:)
+152
(cons "-e" (cons val acc)))
+153
((eq? key period:)
+154
(cons "-p" (cons val acc)))
+155
((eq? key depth:)
+156
(cons "--depth" (cons (if (number? val) (number->string val) val) acc)))
+157
((eq? key tree:)
+158
(if val (cons "--tree" acc) acc))
+159
((eq? key monthly:)
+160
(if val (cons "-M" acc) acc))
+161
((eq? key quarterly:)
+162
(if val (cons "-Q" acc) acc))
+163
((eq? key yearly:)
+164
(if val (cons "-Y" acc) acc))
+165
((eq? key output-format:)
+166
(cons "-O" (cons val acc)))
+167
((eq? key cost:)
+168
(if val (cons "-B" acc) acc))
+169
((eq? key market:)
+170
(if val (cons "-V" acc) acc))
+171
((eq? key exchange:)
+172
(cons "-X" (cons val acc)))
+173
(else acc))))
+174
'()
+175
opts)))
+176
(append base with-opts query))))))
test/test-ledger.sgladded
@@ -0,0 +1,387 @@
+1
;;; Test suite for (sigil ledger)
+2
+3
(import (sigil test)
+4
(sigil string)
+5
(sigil ledger))
+6
+7
;; ========== Transaction Formatting ==========
+8
+9
(test-group "format-transaction - basic"
+10
(test "format simple transaction"
+11
(let ((txn (journal-transaction
+12
date: "2026-03-15"
+13
status: "*"
+14
description: "Grocery store"
+15
postings: (list
+16
(journal-posting
+17
account: "expenses:food:groceries"
+18
amount: (journal-amount quantity: 47.23 commodity: "USD"))
+19
(journal-posting
+20
account: "assets:checking")))))
+21
(assert-equal
+22
"2026-03-15 * Grocery store\n expenses:food:groceries 47.23 USD\n assets:checking"
+23
(format-transaction txn))))
+24
+25
(test "format transaction with pending status"
+26
(let ((txn (journal-transaction
+27
date: "2026-03-15"
+28
status: "!"
+29
description: "Pending purchase"
+30
postings: (list
+31
(journal-posting
+32
account: "expenses:food"
+33
amount: (journal-amount quantity: 10 commodity: "USD"))
+34
(journal-posting
+35
account: "assets:checking")))))
+36
(assert-equal
+37
"2026-03-15 ! Pending purchase\n expenses:food 10.00 USD\n assets:checking"
+38
(format-transaction txn))))
+39
+40
(test "format transaction with no status"
+41
(let ((txn (journal-transaction
+42
date: "2026-03-15"
+43
description: "Some purchase"
+44
postings: (list
+45
(journal-posting
+46
account: "expenses:food"
+47
amount: (journal-amount quantity: 10 commodity: "USD"))
+48
(journal-posting
+49
account: "assets:checking")))))
+50
(assert-equal
+51
"2026-03-15 Some purchase\n expenses:food 10.00 USD\n assets:checking"
+52
(format-transaction txn)))))
+53
+54
(test-group "format-transaction - code and description"
+55
(test "format transaction with code"
+56
(let ((txn (journal-transaction
+57
date: "2026-03-15"
+58
status: "*"
+59
code: "ref-001"
+60
description: "Whole Foods | groceries"
+61
postings: (list
+62
(journal-posting
+63
account: "expenses:food"
+64
amount: (journal-amount quantity: 47.23 commodity: "$"))
+65
(journal-posting
+66
account: "assets:checking")))))
+67
(assert-equal
+68
"2026-03-15 * (ref-001) Whole Foods | groceries\n expenses:food $47.23\n assets:checking"
+69
(format-transaction txn)))))
+70
+71
(test-group "format-transaction - tags"
+72
(test "format transaction with tags"
+73
(let ((txn (journal-transaction
+74
date: "2026-03-15"
+75
status: "*"
+76
description: "Dinner"
+77
tags: (list (cons "trip" "hawaii"))
+78
postings: (list
+79
(journal-posting
+80
account: "expenses:meals"
+81
amount: (journal-amount quantity: 85 commodity: "$"))
+82
(journal-posting
+83
account: "assets:checking")))))
+84
(assert-equal
+85
"2026-03-15 * Dinner\n ; trip: hawaii\n expenses:meals $85.00\n assets:checking"
+86
(format-transaction txn))))
+87
+88
(test "format posting with inline tags"
+89
(let ((txn (journal-transaction
+90
date: "2026-03-15"
+91
status: "*"
+92
description: "Test"
+93
postings: (list
+94
(journal-posting
+95
account: "expenses:food"
+96
amount: (journal-amount quantity: 50 commodity: "USD")
+97
tags: (list (cons "receipt" "scan-001")))
+98
(journal-posting
+99
account: "assets:checking")))))
+100
(assert-equal
+101
"2026-03-15 * Test\n expenses:food 50.00 USD ; receipt: scan-001\n assets:checking"
+102
(format-transaction txn)))))
+103
+104
(test-group "format-transaction - cost notation"
+105
(test "format total cost (@@)"
+106
(let ((txn (journal-transaction
+107
date: "2026-03-15"
+108
status: "*"
+109
description: "Currency conversion"
+110
postings: (list
+111
(journal-posting
+112
account: "assets:wise:eur"
+113
amount: (journal-amount quantity: -500 commodity: "EUR"))
+114
(journal-posting
+115
account: "assets:wise:usd"
+116
amount: (journal-amount quantity: 540 commodity: "USD")
+117
cost: (journal-cost
+118
type: 'total
+119
amount: (journal-amount quantity: 500 commodity: "EUR")))))))
+120
(assert-equal
+121
"2026-03-15 * Currency conversion\n assets:wise:eur -500.00 EUR\n assets:wise:usd 540.00 USD @@ 500.00 EUR"
+122
(format-transaction txn))))
+123
+124
(test "format per-unit cost (@)"
+125
(let ((txn (journal-transaction
+126
date: "2026-03-15"
+127
status: "*"
+128
description: "Currency conversion"
+129
postings: (list
+130
(journal-posting
+131
account: "assets:wise:eur"
+132
amount: (journal-amount quantity: 100 commodity: "EUR")
+133
cost: (journal-cost
+134
type: 'per-unit
+135
amount: (journal-amount quantity: 1.08 commodity: "USD")))
+136
(journal-posting
+137
account: "assets:wise:usd")))))
+138
(assert-equal
+139
"2026-03-15 * Currency conversion\n assets:wise:eur 100.00 EUR @ 1.08 USD\n assets:wise:usd"
+140
(format-transaction txn)))))
+141
+142
(test-group "format-transaction - balance assertions"
+143
(test "format balance assertion"
+144
(let ((txn (journal-transaction
+145
date: "2026-03-15"
+146
status: "*"
+147
description: "Deposit"
+148
postings: (list
+149
(journal-posting
+150
account: "assets:checking"
+151
amount: (journal-amount quantity: 3000 commodity: "$")
+152
balance-assertion: (journal-amount quantity: 5000 commodity: "$"))
+153
(journal-posting
+154
account: "income:salary")))))
+155
(assert-equal
+156
"2026-03-15 * Deposit\n assets:checking $3000.00 = $5000.00\n income:salary"
+157
(format-transaction txn)))))
+158
+159
(test-group "format-transactions"
+160
(test "format multiple transactions"
+161
(let ((txns (list
+162
(journal-transaction
+163
date: "2026-03-15"
+164
status: "*"
+165
description: "Groceries"
+166
postings: (list
+167
(journal-posting
+168
account: "expenses:food"
+169
amount: (journal-amount quantity: 47.23 commodity: "USD"))
+170
(journal-posting
+171
account: "assets:checking")))
+172
(journal-transaction
+173
date: "2026-03-16"
+174
status: "*"
+175
description: "Gas"
+176
postings: (list
+177
(journal-posting
+178
account: "expenses:transport"
+179
amount: (journal-amount quantity: 30 commodity: "USD"))
+180
(journal-posting
+181
account: "assets:checking"))))))
+182
(assert-equal
+183
"2026-03-15 * Groceries\n expenses:food 47.23 USD\n assets:checking\n\n2026-03-16 * Gas\n expenses:transport 30.00 USD\n assets:checking"
+184
(format-transactions txns)))))
+185
+186
;; ========== Journal Parsing ==========
+187
+188
(test-group "parse-journal - basic"
+189
(test "parse simple transaction"
+190
(let* ((text "2026-03-15 * Grocery store\n expenses:food $47.23\n assets:checking\n")
+191
(txns (parse-journal text)))
+192
(assert-equal 1 (length txns))
+193
(let ((txn (car txns)))
+194
(assert-equal "2026-03-15" (journal-transaction-date txn))
+195
(assert-equal "*" (journal-transaction-status txn))
+196
(assert-equal "Grocery store" (journal-transaction-description txn))
+197
(assert-equal 2 (length (journal-transaction-postings txn))))))
+198
+199
(test "parse transaction with pending status"
+200
(let* ((text "2026-03-15 ! Pending purchase\n expenses:food $10.00\n assets:checking\n")
+201
(txns (parse-journal text)))
+202
(assert-equal "!" (journal-transaction-status (car txns)))))
+203
+204
(test "parse transaction with no status"
+205
(let* ((text "2026-03-15 Some purchase\n expenses:food $10.00\n assets:checking\n")
+206
(txns (parse-journal text)))
+207
(assert-equal "" (journal-transaction-status (car txns)))
+208
(assert-equal "Some purchase" (journal-transaction-description (car txns)))))
+209
+210
(test "parse transaction with code"
+211
(let* ((text "2026-03-15 * (ref-001) Whole Foods\n expenses:food $47.23\n assets:checking\n")
+212
(txns (parse-journal text)))
+213
(let ((txn (car txns)))
+214
(assert-equal "ref-001" (journal-transaction-code txn))
+215
(assert-equal "Whole Foods" (journal-transaction-description txn)))))
+216
+217
(test "parse multiple transactions"
+218
(let* ((text (str "2026-03-15 * Groceries\n"
+219
" expenses:food $47.23\n"
+220
" assets:checking\n"
+221
"\n"
+222
"2026-03-16 * Gas\n"
+223
" expenses:transport $30.00\n"
+224
" assets:checking\n"))
+225
(txns (parse-journal text)))
+226
(assert-equal 2 (length txns))
+227
(assert-equal "Groceries" (journal-transaction-description (car txns)))
+228
(assert-equal "Gas" (journal-transaction-description (cadr txns))))))
+229
+230
(test-group "parse-journal - amounts"
+231
(test "parse left-symbol commodity"
+232
(let* ((text "2026-03-15 * Test\n expenses:food $47.23\n assets:checking\n")
+233
(txns (parse-journal text))
+234
(posting (car (journal-transaction-postings (car txns)))))
+235
(assert-equal "$" (journal-amount-commodity (journal-posting-amount posting)))
+236
(assert-equal 47.23 (journal-amount-quantity (journal-posting-amount posting)))))
+237
+238
(test "parse right-symbol commodity"
+239
(let* ((text "2026-03-15 * Test\n assets:wise:eur 100.00 EUR\n assets:checking\n")
+240
(txns (parse-journal text))
+241
(posting (car (journal-transaction-postings (car txns)))))
+242
(assert-equal "EUR" (journal-amount-commodity (journal-posting-amount posting)))
+243
(assert-equal 100.0 (journal-amount-quantity (journal-posting-amount posting)))))
+244
+245
(test "parse negative amount"
+246
(let* ((text "2026-03-15 * Test\n assets:wise:eur -500.00 EUR\n assets:checking\n")
+247
(txns (parse-journal text))
+248
(posting (car (journal-transaction-postings (car txns)))))
+249
(assert-equal -500.0 (journal-amount-quantity (journal-posting-amount posting)))))
+250
+251
(test "parse inferred amount posting"
+252
(let* ((text "2026-03-15 * Test\n expenses:food $50.00\n assets:checking\n")
+253
(txns (parse-journal text))
+254
(posting (cadr (journal-transaction-postings (car txns)))))
+255
(assert-false (journal-posting-amount posting)))))
+256
+257
(test-group "parse-journal - cost notation"
+258
(test "parse per-unit cost"
+259
(let* ((text "2026-03-15 * Test\n assets:wise:eur 100.00 EUR @ 1.08 USD\n assets:wise:usd\n")
+260
(txns (parse-journal text))
+261
(posting (car (journal-transaction-postings (car txns))))
+262
(cost (journal-posting-cost posting)))
+263
(assert-true (journal-cost? cost))
+264
(assert-equal 'per-unit (journal-cost-type cost))
+265
(assert-equal 1.08 (journal-amount-quantity (journal-cost-amount cost)))
+266
(assert-equal "USD" (journal-amount-commodity (journal-cost-amount cost)))))
+267
+268
(test "parse total cost"
+269
(let* ((text "2026-03-15 * Test\n assets:wise:usd 540.00 USD @@ 500.00 EUR\n assets:wise:eur\n")
+270
(txns (parse-journal text))
+271
(posting (car (journal-transaction-postings (car txns))))
+272
(cost (journal-posting-cost posting)))
+273
(assert-true (journal-cost? cost))
+274
(assert-equal 'total (journal-cost-type cost))
+275
(assert-equal 500.0 (journal-amount-quantity (journal-cost-amount cost)))
+276
(assert-equal "EUR" (journal-amount-commodity (journal-cost-amount cost))))))
+277
+278
(test-group "parse-journal - comments and tags"
+279
(test "parse transaction with tag comment"
+280
(let* ((text "2026-03-15 * Dinner\n ; trip: hawaii\n expenses:meals $85.00\n assets:checking\n")
+281
(txns (parse-journal text))
+282
(txn (car txns))
+283
(tags (journal-transaction-tags txn)))
+284
(assert-equal 1 (length tags))
+285
(assert-equal "trip" (caar tags))
+286
(assert-equal "hawaii" (cdar tags))))
+287
+288
(test "parse posting with inline comment"
+289
(let* ((text "2026-03-15 * Test\n expenses:food $50.00 ; receipt: scan-001\n assets:checking\n")
+290
(txns (parse-journal text))
+291
(posting (car (journal-transaction-postings (car txns)))))
+292
(assert-equal 1 (length (journal-posting-tags posting)))
+293
(assert-equal "receipt" (caar (journal-posting-tags posting)))
+294
(assert-equal "scan-001" (cdar (journal-posting-tags posting))))))
+295
+296
(test-group "parse-journal - balance assertions"
+297
(test "parse balance assertion"
+298
(let* ((text "2026-03-15 * Deposit\n assets:checking $3000.00 = $5000.00\n income:salary\n")
+299
(txns (parse-journal text))
+300
(posting (car (journal-transaction-postings (car txns))))
+301
(bal (journal-posting-balance-assertion posting)))
+302
(assert-true (journal-amount? bal))
+303
(assert-equal 5000.0 (journal-amount-quantity bal))
+304
(assert-equal "$" (journal-amount-commodity bal)))))
+305
+306
;; ========== Round-trip Tests ==========
+307
+308
(test-group "round-trip"
+309
(test "parse and re-format simple transaction"
+310
(let* ((original (str "2026-03-15 * Grocery store\n"
+311
" expenses:food:groceries 47.23 USD\n"
+312
" assets:checking"))
+313
(txns (parse-journal (str original "\n")))
+314
(formatted (format-transaction (car txns))))
+315
(assert-equal original formatted)))
+316
+317
(test "parse and re-format transaction with cost"
+318
(let* ((original (str "2026-03-15 * Currency conversion\n"
+319
" assets:wise:eur -500.00 EUR\n"
+320
" assets:wise:usd 540.00 USD @@ 500.00 EUR"))
+321
(txns (parse-journal (str original "\n")))
+322
(formatted (format-transaction (car txns))))
+323
(assert-equal original formatted)))
+324
+325
(test "parse and re-format multiple transactions"
+326
(let* ((original (str "2026-03-15 * Groceries\n"
+327
" expenses:food 47.23 USD\n"
+328
" assets:checking\n"
+329
"\n"
+330
"2026-03-16 * Gas\n"
+331
" expenses:transport 30.00 USD\n"
+332
" assets:checking"))
+333
(txns (parse-journal (str original "\n")))
+334
(formatted (format-transactions txns)))
+335
(assert-equal original formatted))))
+336
+337
;; ========== Deduplication ==========
+338
+339
(test-group "deduplicate-transactions"
+340
(test "filter out duplicate by ref tag"
+341
(let* ((existing (list
+342
(journal-transaction
+343
date: "2026-03-15"
+344
status: "*"
+345
description: "Existing"
+346
tags: (list (cons "ref" "TRF-001"))
+347
postings: (list
+348
(journal-posting account: "expenses:food"
+349
amount: (journal-amount quantity: 50 commodity: "USD"))
+350
(journal-posting account: "assets:checking")))))
+351
(new-txns (list
+352
(journal-transaction
+353
date: "2026-03-16"
+354
status: "*"
+355
description: "Already imported"
+356
tags: (list (cons "ref" "TRF-001"))
+357
postings: (list
+358
(journal-posting account: "expenses:food"
+359
amount: (journal-amount quantity: 50 commodity: "USD"))
+360
(journal-posting account: "assets:checking")))
+361
(journal-transaction
+362
date: "2026-03-17"
+363
status: "*"
+364
description: "New one"
+365
tags: (list (cons "ref" "TRF-002"))
+366
postings: (list
+367
(journal-posting account: "expenses:food"
+368
amount: (journal-amount quantity: 30 commodity: "USD"))
+369
(journal-posting account: "assets:checking")))))
+370
(result (deduplicate-transactions new-txns existing)))
+371
(assert-equal 1 (length result))
+372
(assert-equal "New one" (journal-transaction-description (car result)))))
+373
+374
(test "keep transactions without ref"
+375
(let* ((existing '())
+376
(new-txns (list
+377
(journal-transaction
+378
date: "2026-03-15"
+379
description: "No ref"
+380
postings: (list
+381
(journal-posting account: "expenses:misc"
+382
amount: (journal-amount quantity: 10 commodity: "USD"))
+383
(journal-posting account: "assets:checking")))))
+384
(result (deduplicate-transactions new-txns existing)))
+385
(assert-equal 1 (length result)))))
+386
+387
(run-tests)