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