1#![forbid(unsafe_code)]
36#![warn(missing_docs)]
37#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
46#![allow(clippy::missing_const_for_fn)]
48
49mod cache;
51pub mod convert;
56mod editor;
57mod helpers;
58mod utils;
59
60pub mod types;
62
63mod api;
65mod parsed_ledger;
66
67pub use api::{balances, format, parse, query, validate_source, version};
69pub use api::{hash_sources, parse_multi_file, query_multi_file, validate_multi_file};
70
71#[cfg(feature = "completions")]
72pub use api::bql_completions;
73
74#[cfg(feature = "plugins")]
75pub use api::{list_plugins, run_plugin};
76
77pub use api::expand_pads;
78pub use parsed_ledger::{Ledger, ParsedLedger};
79
80use wasm_bindgen::prelude::*;
81
82#[wasm_bindgen(typescript_custom_section)]
104const TS_TYPES_DTOS: &'static str = include_str!("../bindings/index.d.ts");
105
106#[wasm_bindgen(typescript_custom_section)]
107const TS_TYPES: &'static str = r#"
108/**
109 * A parsed and validated ledger that caches the parse result.
110 * Use this class when you need to perform multiple operations on the same
111 * source without re-parsing each time.
112 */
113export class ParsedLedger {
114 constructor(source: string);
115 free(): void;
116
117 /** Check if the ledger is valid (no parse or validation errors). */
118 isValid(): boolean;
119
120 /** Get all errors (parse + validation). */
121 getErrors(): BeancountError[];
122
123 /** Get parse errors only. */
124 getParseErrors(): BeancountError[];
125
126 /** Get validation errors only. */
127 getValidationErrors(): BeancountError[];
128
129 /** Get the parsed directives. */
130 getDirectives(): DirectiveJson[];
131
132 /** Get the ledger options. */
133 getOptions(): LedgerOptions;
134
135 /** Get the number of directives. */
136 directiveCount(): number;
137
138 /** Run a BQL query on this ledger. */
139 query(queryStr: string): QueryResult;
140
141 /** Get account balances (shorthand for query("BALANCES")). */
142 balances(): QueryResult;
143
144 /** Format the ledger source. */
145 format(): FormatResult;
146
147 /** Expand pad directives. */
148 expandPads(): PadResult;
149
150 /** Run a native plugin on this ledger. */
151 runPlugin(pluginName: string): PluginResult;
152
153 // =========================================================================
154 // Editor Integration (LSP-like features)
155 // =========================================================================
156
157 /** Get completions at the given position. */
158 getCompletions(line: number, character: number): EditorCompletionResult;
159
160 /** Get hover information at the given position. */
161 getHoverInfo(line: number, character: number): EditorHoverInfo | null;
162
163 /** Get the definition location for the symbol at the given position. */
164 getDefinition(line: number, character: number): EditorLocation | null;
165
166 /** Get all document symbols for the outline view. */
167 getDocumentSymbols(): EditorDocumentSymbol[];
168
169 /** Find all references to the symbol at the given position. */
170 getReferences(line: number, character: number): EditorReferencesResult | null;
171
172 /** Serialize this ledger to a compact binary blob for caching. */
173 serialize(): Uint8Array;
174
175 /**
176 * Restore a ParsedLedger from cached bytes.
177 * The source must be the same text used when the cache was created.
178 * Throws if the bytes are invalid or from a different library version.
179 */
180 static fromCache(bytes: Uint8Array, source: string): ParsedLedger;
181}
182
183/**
184 * A fully processed multi-file ledger for queries and validation.
185 * Use this class for ledgers spanning multiple files with include directives.
186 * For single-file ledgers with editor features, use ParsedLedger instead.
187 */
188export class Ledger {
189 free(): void;
190
191 /** Create from multiple files with include resolution. */
192 static fromFiles(files: FileMap, entryPoint: string): Ledger;
193
194 /** Check if the ledger is valid (no errors). */
195 isValid(): boolean;
196
197 /** Get all errors. */
198 getErrors(): BeancountError[];
199
200 /** Get the parsed directives. */
201 getDirectives(): DirectiveJson[];
202
203 /** Get the ledger options. */
204 getOptions(): LedgerOptions;
205
206 /** Get the number of directives. */
207 directiveCount(): number;
208
209 /** Run a BQL query on this ledger. */
210 query(queryStr: string): QueryResult;
211
212 /** Get account balances (shorthand for query("BALANCES")). */
213 balances(): QueryResult;
214
215 /** Expand pad directives. */
216 expandPads(): PadResult;
217
218 /** Run a native plugin on this ledger. */
219 runPlugin(pluginName: string): PluginResult;
220
221 /** Get completions using cross-file data. Pass the source of the file being edited. */
222 getCompletions(source: string, line: number, character: number): EditorCompletionResult;
223
224 /** Serialize this ledger to a compact binary blob for caching. */
225 serialize(): Uint8Array;
226
227 /**
228 * Restore a Ledger from cached bytes.
229 * Throws if the bytes are invalid or from a different library version.
230 */
231 static fromCache(bytes: Uint8Array): Ledger;
232}
233
234// =============================================================================
235// Multi-File API (for WASM environments without filesystem access)
236// =============================================================================
237
238/** Map of file paths to their contents. */
239export type FileMap = Record<string, string>;
240
241/**
242 * Parse multiple Beancount files with include resolution.
243 *
244 * @param files - Object mapping file paths to their contents
245 * @param entryPoint - The main file to start loading from (must exist in files)
246 * @returns ParseResult with the combined ledger from all files
247 *
248 * @example
249 * const result = parseMultiFile({
250 * "main.beancount": 'include "accounts.beancount"',
251 * "accounts.beancount": "2024-01-01 open Assets:Bank USD"
252 * }, "main.beancount");
253 */
254export function parseMultiFile(files: FileMap, entryPoint: string): ParseResult;
255
256/**
257 * Validate multiple Beancount files with include resolution.
258 *
259 * @param files - Object mapping file paths to their contents
260 * @param entryPoint - The main file to start loading from (must exist in files)
261 * @returns ValidationResult indicating whether the combined ledger is valid
262 */
263export function validateMultiFile(files: FileMap, entryPoint: string): ValidationResult;
264
265/**
266 * Run a BQL query on multiple Beancount files.
267 *
268 * @param files - Object mapping file paths to their contents
269 * @param entryPoint - The main file to start loading from (must exist in files)
270 * @param query - The BQL query string to execute
271 * @returns QueryResult with columns, rows, and any errors
272 */
273export function queryMultiFile(files: FileMap, entryPoint: string, query: string): QueryResult;
274
275/**
276 * Compute a SHA-256 fingerprint of one or more source strings.
277 *
278 * Returns a lowercase hex string. Store alongside serialized ledger bytes
279 * and compare on next load; if the fingerprint changed, discard the cache.
280 *
281 * @param sources - Array of source strings
282 * @returns Lowercase hex SHA-256 hash
283 */
284export function hashSources(sources: string[]): string;
285"#;
286
287#[wasm_bindgen(start)]
296pub fn init() {
297 console_error_panic_hook::set_once();
299}
300
301#[cfg(test)]
306mod tests {
307 use super::*;
308 use rustledger_parser::parse as parse_beancount;
309 use rustledger_validate::{ValidationOptions, ValidationSession};
310
311 fn validate_ledger(
315 directives: &[rustledger_core::Directive],
316 ) -> Vec<rustledger_validate::ValidationError> {
317 let today = rustledger_core::naive_date(2999, 12, 31).unwrap();
318 let session = ValidationSession::new(ValidationOptions::default());
319 let (session, mut errors) = session.run_early(directives, today);
320 let (session, late_errs) = session.run_late(directives, today);
321 errors.extend(late_errs);
322 errors.extend(session.finalize());
323 errors
324 }
325
326 #[test]
327 fn test_parse_simple() {
328 let source = r#"
3292024-01-01 open Assets:Bank USD
330
3312024-01-15 * "Coffee Shop" "Morning coffee"
332 Expenses:Food:Coffee 5.00 USD
333 Assets:Bank -5.00 USD
334"#;
335
336 let result = parse_beancount(source);
337 assert!(result.errors.is_empty());
338 assert_eq!(result.directives.len(), 2);
339 }
340
341 #[test]
342 fn test_version() {
343 let v = version();
344 assert!(!v.is_empty());
345 }
346
347 #[test]
348 fn test_load_and_book() {
349 use helpers::load_and_book;
350
351 let source = r#"
3532024-01-01 open Assets:Bank USD
3542024-01-01 open Expenses:Food USD
355
3562024-01-15 * "Coffee"
357 Expenses:Food 5.00 USD
358 Assets:Bank -5.00 USD
359"#;
360 let load = load_and_book(source);
361 assert!(load.errors.is_empty());
362 assert_eq!(load.directives.len(), 3);
363
364 let source = r#"
3662024-01-01 open Assets:Bank USD
367
3682024-01-15 * "Coffee"
369 Expenses:Food 5.00 USD
370 Assets:Bank -5.00 USD
371"#;
372 let load = load_and_book(source);
373 assert!(load.errors.is_empty()); let validation_errors = validate_ledger(&load.directives);
375 assert!(
376 !validation_errors.is_empty(),
377 "should detect Expenses:Food not opened"
378 );
379 }
380
381 #[test]
386 fn test_multi_file_include_resolution() {
387 use rustledger_loader::{Loader, VirtualFileSystem};
388 use std::path::Path;
389
390 let mut vfs = VirtualFileSystem::new();
391 vfs.add_file(
392 "main.beancount",
393 r#"
394include "accounts.beancount"
395
3962024-01-15 * "Coffee"
397 Expenses:Food 5.00 USD
398 Assets:Bank -5.00 USD
399"#,
400 );
401 vfs.add_file(
402 "accounts.beancount",
403 r"
4042024-01-01 open Assets:Bank USD
4052024-01-01 open Expenses:Food USD
406",
407 );
408
409 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
410 let result = loader.load(Path::new("main.beancount")).unwrap();
411
412 assert!(result.errors.is_empty(), "should have no errors");
413 assert_eq!(result.directives.len(), 3);
415 }
416
417 #[test]
418 fn test_multi_file_nested_includes() {
419 use rustledger_loader::{Loader, VirtualFileSystem};
420 use std::path::Path;
421
422 let mut vfs = VirtualFileSystem::new();
423 vfs.add_file("main.beancount", r#"include "accounts/index.beancount""#);
424 vfs.add_file(
425 "accounts/index.beancount",
426 r#"
427include "assets.beancount"
428include "expenses.beancount"
429"#,
430 );
431 vfs.add_file(
432 "accounts/assets.beancount",
433 "2024-01-01 open Assets:Bank USD",
434 );
435 vfs.add_file(
436 "accounts/expenses.beancount",
437 "2024-01-01 open Expenses:Food USD",
438 );
439
440 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
441 let result = loader.load(Path::new("main.beancount")).unwrap();
442
443 assert!(result.errors.is_empty(), "should have no errors");
444 assert_eq!(result.directives.len(), 2); }
446
447 #[test]
448 fn test_multi_file_validation() {
449 use rustledger_booking::BookingEngine;
450 use rustledger_core::Directive;
451 use rustledger_loader::{Loader, VirtualFileSystem};
452 use std::path::Path;
453
454 let mut vfs = VirtualFileSystem::new();
455 vfs.add_file(
456 "main.beancount",
457 r#"
458include "accounts.beancount"
459
4602024-01-15 * "Coffee"
461 Expenses:Food 5.00 USD
462 Assets:Bank
463"#,
464 );
465 vfs.add_file(
466 "accounts.beancount",
467 r"
4682024-01-01 open Assets:Bank USD
4692024-01-01 open Expenses:Food USD
470",
471 );
472
473 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
474 let result = loader.load(Path::new("main.beancount")).unwrap();
475
476 assert!(result.errors.is_empty());
477
478 let mut directives: Vec<_> = result.directives.into_iter().map(|s| s.value).collect();
480 let mut engine = BookingEngine::new();
481 engine.register_account_methods(directives.iter());
482 for directive in &mut directives {
483 if let Directive::Transaction(txn) = directive
484 && let Ok(result) = engine.book_and_interpolate(txn)
485 {
486 engine.apply(&result.transaction);
487 *txn = result.transaction;
488 }
489 }
490 directives.sort_by_key(rustledger_core::Directive::date);
492 let validation_errors = validate_ledger(&directives);
493 assert!(
494 validation_errors.is_empty(),
495 "ledger should be valid, but got: {validation_errors:?}"
496 );
497 }
498
499 #[test]
501 fn test_parsed_ledger_multi_file_via_process() {
502 use rustledger_core::Directive;
503 use rustledger_loader::{FileSystem, LoadOptions, Loader, VirtualFileSystem, process};
504 use std::path::Path;
505
506 let mut vfs = VirtualFileSystem::new();
507 vfs.add_file(
508 "main.beancount",
509 r#"
510include "accounts.beancount"
511
5122024-01-15 * "Coffee"
513 Expenses:Food 5.00 USD
514 Assets:Bank
515"#,
516 );
517 vfs.add_file(
518 "accounts.beancount",
519 r"
5202024-01-01 open Assets:Bank USD
5212024-01-01 open Expenses:Food USD
522",
523 );
524
525 assert!(vfs.exists(Path::new("main.beancount")));
526
527 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
528 let raw = loader.load(Path::new("main.beancount")).unwrap();
529
530 let options = LoadOptions {
531 validate: true,
532 ..Default::default()
533 };
534
535 let ledger = process(raw, &options).unwrap();
536 let directives: Vec<_> = ledger.directives.into_iter().map(|s| s.value).collect();
537
538 assert_eq!(directives.len(), 3);
540
541 let dates: Vec<_> = directives
543 .iter()
544 .map(rustledger_core::Directive::date)
545 .collect();
546 assert!(dates.windows(2).all(|w| w[0] <= w[1]));
547
548 let txn = directives
550 .iter()
551 .find_map(|d| match d {
552 Directive::Transaction(t) => Some(t),
553 _ => None,
554 })
555 .expect("should have transaction");
556
557 let bank = txn
558 .postings
559 .iter()
560 .find(|p| p.account.as_str().contains("Bank"))
561 .expect("should have bank posting");
562 assert!(
563 bank.units
564 .as_ref()
565 .and_then(rustledger_core::IncompleteAmount::number)
566 .is_some(),
567 "bank amount should be interpolated"
568 );
569
570 assert!(ledger.errors.is_empty(), "errors: {:?}", ledger.errors);
572 }
573
574 #[test]
576 fn test_total_cost_produces_per_unit_cost() {
577 use helpers::load_and_book;
578 use rustledger_core::Directive;
579 use std::str::FromStr;
580
581 let source = r#"
5822020-01-01 open Assets:Investments:PROP PROP
5832020-01-01 open Assets:Bank AUD
584
5852020-01-16 * "Buy PROP"
586 Assets:Investments:PROP 273.2200 PROP {{150.00 AUD}}
587 Assets:Bank -150.00 AUD
588"#;
589 let load = load_and_book(source);
590 assert!(load.errors.is_empty(), "errors: {:?}", load.errors);
591
592 let txn = load
595 .directives
596 .iter()
597 .find_map(|d| match d {
598 Directive::Transaction(txn) => Some(txn),
599 _ => None,
600 })
601 .expect("should have at least one transaction");
602
603 let prop_posting = txn
604 .postings
605 .iter()
606 .find(|p| {
607 p.units
608 .as_ref()
609 .is_some_and(|u| u.currency() == Some("PROP"))
610 })
611 .expect("should have PROP posting");
612
613 let cost = prop_posting.cost.as_ref().expect("should have cost");
614 let per_unit = cost
615 .number
616 .as_ref()
617 .and_then(rustledger_core::CostNumber::per_unit)
618 .expect("total cost {{}} should be booked into a CostNumber that exposes per_unit()");
619
620 let expected = rustledger_core::Decimal::from_str("0.5490").unwrap();
622 let diff = (per_unit - expected).abs();
623 assert!(
624 diff < rustledger_core::Decimal::from_str("0.001").unwrap(),
625 "per-unit cost should be ~0.5490, got {per_unit}"
626 );
627 }
628
629 fn cli_process(source: &str) -> Vec<rustledger_core::Directive> {
635 use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
636 use std::path::Path;
637
638 let mut vfs = VirtualFileSystem::new();
639 vfs.add_file("test.beancount", source);
640 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
641 let raw = loader.load(Path::new("test.beancount")).unwrap();
642 let options = LoadOptions {
643 validate: false,
644 ..Default::default()
645 };
646 let ledger = process(raw, &options).unwrap();
647 ledger.directives.into_iter().map(|s| s.value).collect()
648 }
649
650 fn wasm_process(source: &str) -> Vec<rustledger_core::Directive> {
652 let load = helpers::load_and_book(source);
653 assert!(load.errors.is_empty(), "WASM errors: {:?}", load.errors);
654 load.directives
655 }
656
657 #[test]
659 fn test_parity_sorting() {
660 use rustledger_core::Directive;
661
662 let source = r#"
6632024-01-01 open Assets:Bank USD
6642024-01-01 open Expenses:Food USD
665
6662024-03-01 * "March"
667 Expenses:Food 30 USD
668 Assets:Bank
669
6702024-01-15 * "January"
671 Expenses:Food 10 USD
672 Assets:Bank
673
6742024-02-15 * "February"
675 Expenses:Food 20 USD
676 Assets:Bank
677"#;
678 let cli = cli_process(source);
679 let wasm = wasm_process(source);
680
681 assert_eq!(cli.len(), wasm.len(), "directive count mismatch");
683
684 let cli_dates: Vec<_> = cli.iter().map(rustledger_core::Directive::date).collect();
686 let wasm_dates: Vec<_> = wasm.iter().map(rustledger_core::Directive::date).collect();
687 assert_eq!(cli_dates, wasm_dates, "date order mismatch");
688
689 let txn_dates: Vec<_> = wasm
691 .iter()
692 .filter_map(|d| match d {
693 Directive::Transaction(t) => Some(t.date),
694 _ => None,
695 })
696 .collect();
697 assert!(
698 txn_dates.windows(2).all(|w| w[0] <= w[1]),
699 "transactions not sorted: {txn_dates:?}"
700 );
701 }
702
703 #[test]
705 fn test_parity_total_cost() {
706 fn get_cost_per_unit(
707 directives: &[rustledger_core::Directive],
708 ) -> rustledger_core::Decimal {
709 directives
710 .iter()
711 .find_map(|d| match d {
712 rustledger_core::Directive::Transaction(t) => t.postings.iter().find_map(|p| {
713 p.cost.as_ref().and_then(|c| {
714 c.number
715 .as_ref()
716 .and_then(rustledger_core::CostNumber::per_unit)
717 })
718 }),
719 _ => None,
720 })
721 .expect("should have a cost")
722 }
723
724 let source = r#"
7252020-01-01 open Assets:Investments PROP
7262020-01-01 open Assets:Bank AUD
727
7282020-01-16 * "Buy"
729 Assets:Investments 273.2200 PROP {{150.00 AUD}}
730 Assets:Bank -150.00 AUD
731"#;
732 let cli = cli_process(source);
733 let wasm = wasm_process(source);
734
735 assert_eq!(
736 get_cost_per_unit(&cli),
737 get_cost_per_unit(&wasm),
738 "per-unit cost differs between CLI and WASM"
739 );
740 }
741
742 #[test]
744 fn test_parity_interpolation() {
745 fn get_bank_amount(directives: &[rustledger_core::Directive]) -> rustledger_core::Decimal {
746 directives
747 .iter()
748 .find_map(|d| match d {
749 rustledger_core::Directive::Transaction(t) => t.postings.iter().find_map(|p| {
750 if p.account.as_str().contains("Bank") {
751 p.units
752 .as_ref()
753 .and_then(rustledger_core::IncompleteAmount::number)
754 } else {
755 None
756 }
757 }),
758 _ => None,
759 })
760 .expect("should have bank posting with amount")
761 }
762
763 let source = r#"
7642024-01-01 open Assets:Bank USD
7652024-01-01 open Expenses:Food USD
766
7672024-01-15 * "Coffee"
768 Expenses:Food 5.00 USD
769 Assets:Bank
770"#;
771 let cli = cli_process(source);
772 let wasm = wasm_process(source);
773
774 assert_eq!(
775 get_bank_amount(&cli),
776 get_bank_amount(&wasm),
777 "interpolated amount differs"
778 );
779 }
780
781 #[test]
788 fn test_parity_pad_expansion() {
789 use rustledger_booking::merge_with_padding;
790
791 let source = r"
7922024-01-01 open Assets:Bank USD
7932024-01-01 open Equity:Opening USD
794
7952024-01-01 pad Assets:Bank Equity:Opening
7962024-01-15 balance Assets:Bank 1000 USD
797";
798 let cli = cli_process(source);
799 let wasm = wasm_process(source);
800
801 let cli_merged = merge_with_padding(&cli);
802 let wasm_merged = merge_with_padding(&wasm);
803
804 assert_eq!(
805 cli_merged, wasm_merged,
806 "merged directive vectors differ between CLI and WASM paths",
807 );
808 }
809
810 #[test]
815 fn test_parsed_ledger_serialize_roundtrip() {
816 use crate::cache;
817 use crate::editor::EditorCache;
818 use crate::helpers::load_and_book;
819
820 let source = r#"
821option "title" "Cache Test"
822option "operating_currency" "USD"
823
8242024-01-01 open Assets:Bank USD
8252024-01-01 open Expenses:Food USD
826
8272024-01-15 * "Coffee" "Morning latte"
828 Expenses:Food 5.00 USD
829 Assets:Bank -5.00 USD
830
8312024-01-20 * "Groceries"
832 Expenses:Food 42.50 USD
833 Assets:Bank -42.50 USD
834"#;
835
836 let processed = load_and_book(source);
838 let payload = cache::ParsedLedgerPayload {
839 directives: processed.directives.clone(),
840 options: processed.options.clone(),
841 parse_errors: Vec::new(),
842 validation_errors: Vec::new(),
843 };
844
845 let bytes = cache::serialize_parsed(&payload).expect("serialize");
846 let restored = cache::deserialize_parsed(&bytes).expect("deserialize");
847
848 assert_eq!(
849 restored.directives.len(),
850 processed.directives.len(),
851 "directive count should match after roundtrip"
852 );
853 assert_eq!(
854 restored.options.title.as_deref(),
855 Some("Cache Test"),
856 "title preserved"
857 );
858 assert_eq!(
859 restored.options.operating_currencies,
860 ["USD"],
861 "operating currencies preserved"
862 );
863
864 let parse_result = rustledger_parser::parse(source);
866 let editor_cache = EditorCache::new(source, &parse_result);
867 assert!(
868 !editor_cache.accounts.is_empty(),
869 "editor cache should have accounts after re-parse"
870 );
871 }
872
873 #[test]
874 fn test_ledger_serialize_roundtrip() {
875 use crate::cache;
876 use crate::helpers::load_and_book;
877
878 let source = r#"
879option "title" "Multi Cache"
880option "operating_currency" "EUR"
881
8822024-01-01 open Assets:Bank EUR
8832024-01-01 open Expenses:Rent EUR
884
8852024-02-01 * "Rent"
886 Expenses:Rent 800 EUR
887 Assets:Bank -800 EUR
888"#;
889
890 let processed = load_and_book(source);
891 let payload = cache::LedgerPayload {
892 directives: processed.directives.clone(),
893 options: processed.options.clone(),
894 account_type_names: Vec::new(),
895 errors: Vec::new(),
896 };
897
898 let bytes = cache::serialize_ledger(&payload).expect("serialize");
899 let restored = cache::deserialize_ledger(&bytes).expect("deserialize");
900
901 assert_eq!(
902 restored.directives.len(),
903 processed.directives.len(),
904 "directive count should match after roundtrip"
905 );
906 assert_eq!(restored.options.title.as_deref(), Some("Multi Cache"));
907
908 let editor_cache = crate::editor::EditorCache::from_directives(&restored.directives);
910 assert!(
911 !editor_cache.accounts.is_empty(),
912 "editor cache should have accounts from restored directives"
913 );
914 }
915
916 #[test]
917 fn test_serialize_rejects_corrupted_bytes() {
918 use crate::cache;
919
920 assert!(cache::deserialize_ledger(b"GARBAGE_DATA_HERE").is_err());
922 assert!(cache::deserialize_parsed(b"GARBAGE_DATA_HERE").is_err());
923
924 assert!(cache::deserialize_ledger(b"short").is_err());
926 }
927
928 #[test]
929 fn test_hash_sources_api() {
930 let h1 = hash_sources(vec!["source v1".to_string()]);
931 let h2 = hash_sources(vec!["source v1".to_string()]);
932 let h3 = hash_sources(vec!["source v2".to_string()]);
933
934 assert_eq!(h1, h2, "same content → same hash");
935 assert_ne!(h1, h3, "different content → different hash");
936 assert_eq!(h1.len(), 64, "SHA-256 produces 64 hex chars");
937 }
938}