use rustledger_loader::{LoadError, Loader, load_raw};
use std::path::Path;
fn fixtures_path(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
#[test]
fn test_load_simple_file() {
let path = fixtures_path("simple.beancount");
let result = load_raw(&path).expect("should load simple file");
assert_eq!(result.options.title, Some("Test Ledger".to_string()));
assert_eq!(result.options.operating_currency, vec!["USD".to_string()]);
assert!(!result.directives.is_empty());
let opens = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
.count();
assert_eq!(opens, 3, "expected 3 open directives");
let txns = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
.count();
assert_eq!(txns, 1, "expected 1 transaction");
assert!(result.errors.is_empty(), "expected no errors");
}
#[test]
fn test_load_with_include() {
let path = fixtures_path("main_with_include.beancount");
let result = load_raw(&path).expect("should load file with include");
let opens = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
.count();
assert_eq!(opens, 3, "expected 3 open directives from included file");
let txns = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
.count();
assert_eq!(txns, 1, "expected 1 transaction from main file");
assert_eq!(
result.source_map.files().len(),
2,
"expected 2 files in source map"
);
assert!(result.errors.is_empty(), "expected no errors");
}
#[test]
fn test_load_include_cycle_detection() {
let path = fixtures_path("cycle_a.beancount");
let result = Loader::new().load(&path);
match result {
Err(LoadError::IncludeCycle { cycle }) => {
assert!(cycle.len() >= 2, "cycle should have at least 2 entries");
let cycle_str = cycle.join(" -> ");
assert!(
cycle_str.contains("cycle_a.beancount"),
"cycle should mention cycle_a.beancount"
);
assert!(
cycle_str.contains("cycle_b.beancount"),
"cycle should mention cycle_b.beancount"
);
}
Ok(result) => {
let has_cycle_error = result
.errors
.iter()
.any(|e| matches!(e, LoadError::IncludeCycle { .. }));
assert!(has_cycle_error, "expected include cycle to be detected");
}
Err(e) => panic!("expected IncludeCycle error, got: {e}"),
}
}
#[test]
fn test_include_cycle_display_contains_duplicate_filename_issue_765() {
let path = fixtures_path("cycle_a.beancount");
let result = Loader::new().load(&path);
let err: LoadError = match result {
Err(e @ LoadError::IncludeCycle { .. }) => e,
Ok(result) => result
.errors
.into_iter()
.find(|e| matches!(e, LoadError::IncludeCycle { .. }))
.expect("expected IncludeCycle error in load_result.errors"),
Err(other) => panic!("expected IncludeCycle error, got: {other}"),
};
let rendered = err.to_string();
assert!(
rendered.contains("Duplicate filename"),
"IncludeCycle Display must contain 'Duplicate filename' for \
beancount conformance (#765). Got: {rendered}"
);
assert!(
rendered.contains("cycle_a.beancount"),
"IncludeCycle Display must mention the cycle file. Got: {rendered}"
);
assert!(
rendered.contains("include cycle:"),
"IncludeCycle Display should still preserve the cycle path \
for debuggability. Got: {rendered}"
);
}
#[test]
fn test_load_missing_include() {
let path = fixtures_path("missing_include.beancount");
let result = load_raw(&path).expect("should load file even with missing include");
let has_io_error = result
.errors
.iter()
.any(|e| matches!(e, LoadError::Io { .. }));
assert!(has_io_error, "expected IO error for missing include");
let opens = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
.count();
assert_eq!(opens, 1, "expected 1 open directive from main file");
}
#[test]
fn test_load_with_plugins() {
let path = fixtures_path("with_plugin.beancount");
let result = load_raw(&path).expect("should load file with plugins");
assert_eq!(result.plugins.len(), 2, "expected 2 plugins");
assert_eq!(result.plugins[0].name, "beancount.plugins.leafonly");
assert!(result.plugins[0].config.is_none());
assert_eq!(result.plugins[1].name, "beancount.plugins.check_commodity");
assert_eq!(result.plugins[1].config, Some("config_string".to_string()));
}
#[test]
fn test_load_with_parse_errors() {
let path = fixtures_path("parse_error.beancount");
let result = load_raw(&path).expect("should load file even with parse errors");
let has_parse_error = result
.errors
.iter()
.any(|e| matches!(e, LoadError::ParseErrors { .. }));
assert!(has_parse_error, "expected parse error");
let opens = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
.count();
assert!(
opens >= 1,
"expected at least 1 open directive despite errors"
);
}
#[test]
fn test_load_nonexistent_file() {
let path = fixtures_path("does_not_exist.beancount");
let result = Loader::new().load(&path);
match result {
Err(LoadError::Io { path: err_path, .. }) => {
assert!(
err_path.to_string_lossy().contains("does_not_exist"),
"error should mention the missing file"
);
}
Ok(_) => panic!("expected IO error for nonexistent file"),
Err(e) => panic!("expected IO error, got: {e}"),
}
}
#[test]
fn test_loader_reuse() {
let mut loader = Loader::new();
let path1 = fixtures_path("simple.beancount");
let result1 = loader.load(&path1).expect("should load first file");
assert!(!result1.directives.is_empty());
let path2 = fixtures_path("accounts.beancount");
let mut loader2 = Loader::new();
let result2 = loader2.load(&path2).expect("should load second file");
assert!(!result2.directives.is_empty());
}
#[test]
fn test_source_map_line_lookup() {
let path = fixtures_path("simple.beancount");
let result = load_raw(&path).expect("should load simple file");
assert!(!result.source_map.files().is_empty());
let file = &result.source_map.files()[0];
assert!(file.path.to_string_lossy().contains("simple.beancount"));
if let Some(first) = result.directives.first() {
let (line, col) = file.line_col(first.span.start);
assert!(line >= 1, "line should be >= 1");
assert!(col >= 1, "col should be >= 1");
}
}
#[test]
fn test_duplicate_include_ignored() {
let path = fixtures_path("main_with_include.beancount");
let result = load_raw(&path).expect("should load file");
let file_count = result.source_map.files().len();
assert_eq!(
file_count, 2,
"should have exactly 2 files (main + accounts)"
);
}
#[test]
fn test_glob_include_pattern() {
let path = fixtures_path("glob_test/main.beancount");
let result = load_raw(&path).expect("should load file with glob include");
let opens = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
.count();
assert_eq!(
opens, 3,
"expected 3 open directives (1 from main, 2 from transactions)"
);
let txns = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
.count();
assert_eq!(txns, 2, "expected 2 transactions from glob-matched files");
assert_eq!(
result.source_map.files().len(),
3,
"expected 3 files in source map (main + 2 from glob)"
);
assert!(
result.errors.is_empty(),
"expected no errors, got: {:?}",
result.errors
);
}
#[test]
fn test_glob_include_no_match() {
let path = fixtures_path("glob_nomatch.beancount");
let result = load_raw(&path).expect("should load file even with no-match glob");
let has_glob_error = result
.errors
.iter()
.any(|e| matches!(e, LoadError::GlobNoMatch { .. }));
assert!(
has_glob_error,
"expected GlobNoMatch error for pattern with no matches"
);
let opens = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
.count();
assert_eq!(opens, 1, "expected 1 open directive from main file");
}
#[test]
fn test_glob_include_deterministic_order() {
let path = fixtures_path("glob_test/main.beancount");
let result1 = load_raw(&path).expect("should load file");
let result2 = load_raw(&path).expect("should load file again");
let files1: Vec<_> = result1
.source_map
.files()
.iter()
.map(|f| f.path.clone())
.collect();
let files2: Vec<_> = result2
.source_map
.files()
.iter()
.map(|f| f.path.clone())
.collect();
assert_eq!(
files1, files2,
"file order should be deterministic across loads"
);
}
#[test]
fn test_path_traversal_blocked_with_security_enabled() {
let path = fixtures_path("path_traversal.beancount");
let result = Loader::new()
.with_path_security(true)
.load(&path)
.expect("should load file even with blocked include");
let has_traversal_error = result
.errors
.iter()
.any(|e| matches!(e, LoadError::PathTraversal { .. }));
assert!(
has_traversal_error,
"expected PathTraversal error when security is enabled"
);
let opens = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
.count();
assert_eq!(opens, 1, "expected 1 open directive from main file");
}
#[test]
fn test_path_traversal_allowed_without_security() {
let path = fixtures_path("path_traversal.beancount");
let result = load_raw(&path).expect("should load file");
let has_traversal_error = result
.errors
.iter()
.any(|e| matches!(e, LoadError::PathTraversal { .. }));
assert!(
!has_traversal_error,
"should not have PathTraversal error when security is disabled"
);
}
#[test]
fn test_with_custom_root_dir() {
let path = fixtures_path("main_with_include.beancount");
let fixtures_dir = fixtures_path("");
let result = Loader::new()
.with_root_dir(fixtures_dir)
.load(&path)
.expect("should load file");
let has_traversal_error = result
.errors
.iter()
.any(|e| matches!(e, LoadError::PathTraversal { .. }));
assert!(
!has_traversal_error,
"should not have PathTraversal error for valid include"
);
assert_eq!(result.source_map.files().len(), 2, "should have 2 files");
}
use rustledger_loader::{ErrorSeverity, LedgerError, LoadOptions, load, process};
#[test]
fn test_process_pipeline_with_validation() {
let path = fixtures_path("simple.beancount");
let options = LoadOptions {
validate: true,
..Default::default()
};
let ledger = load(&path, &options).expect("should load and process");
assert!(!ledger.directives.is_empty());
assert_eq!(ledger.options.title, Some("Test Ledger".to_string()));
}
#[test]
fn test_process_pipeline_without_validation() {
let path = fixtures_path("simple.beancount");
let options = LoadOptions {
validate: false,
..Default::default()
};
let ledger = load(&path, &options).expect("should load without validation");
assert!(!ledger.directives.is_empty());
}
#[test]
fn test_process_directives_sorted_by_date() {
let path = fixtures_path("simple.beancount");
let options = LoadOptions::default();
let ledger = load(&path, &options).expect("should load and process");
let mut last_date = None;
for dir in &ledger.directives {
let date = dir.value.date();
if let Some(prev) = last_date {
assert!(
date >= prev,
"directives should be sorted by date: {prev} should come before {date}"
);
}
last_date = Some(date);
}
}
#[test]
fn test_process_raw_mode() {
let path = fixtures_path("simple.beancount");
let options = LoadOptions::raw();
let ledger = load(&path, &options).expect("should load in raw mode");
assert!(!ledger.directives.is_empty());
}
#[test]
fn test_process_with_extra_plugins() {
let path = fixtures_path("simple.beancount");
let options = LoadOptions {
run_plugins: false, extra_plugins: vec!["check_commodity".to_string()],
extra_plugin_configs: vec![None],
..Default::default()
};
let ledger = load(&path, &options).expect("should load with extra plugins");
assert!(!ledger.directives.is_empty());
}
#[test]
fn test_process_with_auto_accounts() {
let path = fixtures_path("simple.beancount");
let options = LoadOptions {
auto_accounts: true,
..Default::default()
};
let ledger = load(&path, &options).expect("should load with auto_accounts");
assert!(!ledger.directives.is_empty());
}
#[test]
fn test_ledger_error_creation() {
use rustledger_loader::ErrorLocation;
let err = LedgerError::error("E001", "Test error message");
assert_eq!(err.code, "E001");
assert_eq!(err.message, "Test error message");
assert!(matches!(err.severity, ErrorSeverity::Error));
assert!(err.location.is_none());
let warn = LedgerError::warning("W001", "Test warning");
assert!(matches!(warn.severity, ErrorSeverity::Warning));
let err_with_loc = LedgerError::error("E002", "Located error").with_location(ErrorLocation {
file: std::path::PathBuf::from("test.beancount"),
line: 10,
column: 5,
});
assert!(err_with_loc.location.is_some());
let loc = err_with_loc.location.unwrap();
assert_eq!(loc.line, 10);
assert_eq!(loc.column, 5);
}
#[test]
fn test_load_options_default() {
let options = LoadOptions::default();
assert!(options.validate);
assert!(options.run_plugins);
assert!(!options.auto_accounts);
assert!(options.extra_plugins.is_empty());
assert!(!options.path_security);
}
#[test]
fn test_load_options_raw() {
let options = LoadOptions::raw();
assert!(!options.validate);
assert!(!options.run_plugins);
assert!(!options.auto_accounts);
}
#[test]
fn test_process_from_load_result() {
let path = fixtures_path("simple.beancount");
let raw = load_raw(&path).expect("should load raw");
let options = LoadOptions {
validate: true,
..Default::default()
};
let ledger = process(raw, &options).expect("should process");
assert!(!ledger.directives.is_empty());
}
#[test]
fn test_process_preserves_display_context() {
let path = fixtures_path("simple.beancount");
let options = LoadOptions::default();
let ledger = load(&path, &options).expect("should load");
let _ctx = &ledger.display_context;
}
#[test]
fn test_file_level_booking_method_applied() {
let path = fixtures_path("booking_method_fifo.beancount");
let options = LoadOptions::default();
let ledger = load(&path, &options).expect("should load and process");
let booking_errors: Vec<_> = ledger.errors.iter().filter(|e| e.code == "BOOK").collect();
assert!(
booking_errors.is_empty(),
"expected no BOOK errors under file-level FIFO, got: {booking_errors:?}"
);
}
#[test]
fn test_api_booking_method_used_when_file_does_not_set_option() {
let path = fixtures_path("simple.beancount");
let options = LoadOptions {
booking_method: rustledger_core::BookingMethod::Fifo,
..Default::default()
};
let ledger = load(&path, &options).expect("should load and process");
assert!(
ledger.errors.is_empty(),
"unexpected errors: {:?}",
ledger.errors
);
}
#[test]
fn test_document_discovery_from_option() {
let path = fixtures_path("doc_discovery.beancount");
let options = LoadOptions::default();
let ledger = load(&path, &options).expect("should load with document discovery");
let documents: Vec<_> = ledger
.directives
.iter()
.filter_map(|d| {
if let rustledger_core::Directive::Document(doc) = &d.value {
Some(doc)
} else {
None
}
})
.collect();
assert_eq!(
documents.len(),
3,
"expected 3 discovered documents, got: {documents:?}"
);
let accounts: Vec<&str> = documents.iter().map(|d| d.account.as_ref()).collect();
assert!(
accounts.contains(&"Assets:Bank:Checking"),
"should have Assets:Bank:Checking document"
);
assert!(
accounts.contains(&"Expenses:Food"),
"should have Expenses:Food document"
);
let dates: Vec<_> = documents.iter().map(|d| d.date.to_string()).collect();
assert!(
dates.contains(&"2024-01-15".to_string()),
"should have document dated 2024-01-15"
);
assert!(
dates.contains(&"2024-02-15".to_string()),
"should have document dated 2024-02-15"
);
assert!(
dates.contains(&"2024-03-10".to_string()),
"should have document dated 2024-03-10"
);
}
#[test]
fn test_document_discovery_no_option() {
let path = fixtures_path("simple.beancount");
let options = LoadOptions::default();
let ledger = load(&path, &options).expect("should load");
let doc_count = ledger
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Document(_)))
.count();
assert_eq!(doc_count, 0, "should have no documents without option");
}
#[test]
fn test_document_discovery_no_duplicates() {
let path = fixtures_path("doc_discovery_with_explicit.beancount");
let options = LoadOptions::default();
let ledger = load(&path, &options).expect("should load");
let doc_count = ledger
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Document(_)))
.count();
assert_eq!(
doc_count, 3,
"document discovery should not create duplicate Document directives"
);
}
#[test]
fn test_plugin_execution_auto_accounts() {
use rustledger_loader::{LoadOptions, load};
let path = fixtures_path("auto_accounts_plugin.beancount");
let ledger = load(&path, &LoadOptions::default()).expect("should load file with plugin");
let opens: Vec<_> = ledger
.directives
.iter()
.filter_map(|d| {
if let rustledger_core::Directive::Open(o) = &d.value {
Some(o.account.to_string())
} else {
None
}
})
.collect();
assert!(
opens.iter().any(|a| a == "Assets:Bank:Checking"),
"auto_accounts should generate Open for Assets:Bank:Checking. Opens: {opens:?}"
);
assert!(
opens.iter().any(|a| a == "Income:Salary"),
"auto_accounts should generate Open for Income:Salary. Opens: {opens:?}"
);
assert!(
opens.iter().any(|a| a == "Expenses:Food"),
"auto_accounts should generate Open for Expenses:Food. Opens: {opens:?}"
);
let validation_errors: Vec<_> = ledger.errors.iter().filter(|e| e.code == "E1001").collect();
assert!(
validation_errors.is_empty(),
"auto_accounts should prevent E1001 (account not opened). Got: {validation_errors:?}"
);
}
#[test]
fn test_plugin_and_booking_interaction() {
use rustledger_loader::{LoadOptions, load};
let path = fixtures_path("fifo_with_plugin.beancount");
let ledger = load(&path, &LoadOptions::default()).expect("should load FIFO + plugin file");
let opens: Vec<_> = ledger
.directives
.iter()
.filter_map(|d| {
if let rustledger_core::Directive::Open(o) = &d.value {
Some(o.account.to_string())
} else {
None
}
})
.collect();
assert!(
opens.iter().any(|a| a == "Assets:Stock"),
"auto_accounts should generate Open for Assets:Stock. Opens: {opens:?}"
);
assert!(
opens.iter().any(|a| a == "Assets:Cash"),
"auto_accounts should generate Open for Assets:Cash. Opens: {opens:?}"
);
let booking_errors: Vec<_> = ledger
.errors
.iter()
.filter(|e| e.message.contains("ambiguous"))
.collect();
assert!(
booking_errors.is_empty(),
"FIFO booking should resolve sell without ambiguity. Errors: {booking_errors:?}"
);
assert!(
ledger.errors.is_empty(),
"No errors expected with FIFO + auto_accounts. Got: {:?}",
ledger.errors
);
}
#[test]
fn test_unknown_plugin_skipped_gracefully() {
use rustledger_loader::{LoadOptions, load};
let path = fixtures_path("unknown_plugin.beancount");
let ledger =
load(&path, &LoadOptions::default()).expect("should load file with unknown plugin");
assert!(
!ledger.directives.is_empty(),
"Ledger should still have directives even with unknown plugin"
);
}
#[test]
fn test_plugin_output_directives_visible_in_ledger() {
use rustledger_loader::{LoadOptions, load};
let path = fixtures_path("auto_accounts_plugin.beancount");
let ledger = load(&path, &LoadOptions::default()).expect("should load");
let total = ledger.directives.len();
let txn_count = ledger
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
.count();
let open_count = ledger
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
.count();
assert_eq!(txn_count, 2, "Should have 2 transactions");
assert!(
open_count >= 3,
"auto_accounts should synthesize at least 3 Open directives. Got {open_count}"
);
assert!(
total >= 5,
"Total directives should be at least 5 (2 txn + 3 opens). Got {total}"
);
}