use crate::{LoadError, LoadResult, Options, Plugin, SourceMap};
use rustledger_core::{BookingMethod, Directive, DisplayContext};
use rustledger_parser::Spanned;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Clone)]
pub struct ExtraPlugin {
pub name: String,
pub config: Option<String>,
}
#[derive(Debug, Clone)]
pub struct LoadOptions {
pub booking_method: BookingMethod,
pub run_plugins: bool,
pub auto_accounts: bool,
pub extra_plugins: Vec<ExtraPlugin>,
pub validate: bool,
pub path_security: bool,
}
impl Default for LoadOptions {
fn default() -> Self {
Self {
booking_method: BookingMethod::Strict,
run_plugins: true,
auto_accounts: false,
extra_plugins: Vec::new(),
validate: true,
path_security: false,
}
}
}
impl LoadOptions {
#[must_use]
pub const fn raw() -> Self {
Self {
booking_method: BookingMethod::Strict,
run_plugins: false,
auto_accounts: false,
extra_plugins: Vec::new(),
validate: false,
path_security: false,
}
}
}
#[derive(Debug, Error)]
pub enum ProcessError {
#[error("loading failed: {0}")]
Load(#[from] LoadError),
#[cfg(feature = "booking")]
#[error("booking error: {message}")]
Booking {
message: String,
date: rustledger_core::NaiveDate,
narration: String,
},
#[cfg(feature = "plugins")]
#[error("plugin error: {0}")]
Plugin(String),
#[cfg(feature = "validation")]
#[error("validation error: {0}")]
Validation(String),
#[cfg(feature = "plugins")]
#[error("failed to convert plugin output: {0}")]
PluginConversion(String),
}
#[derive(Debug)]
pub struct Ledger {
pub directives: Vec<Spanned<Directive>>,
pub options: Options,
pub plugins: Vec<Plugin>,
pub source_map: SourceMap,
pub errors: Vec<LedgerError>,
pub display_context: DisplayContext,
}
impl Ledger {
#[cfg(feature = "booking")]
#[must_use]
pub fn balance_view(&self) -> Vec<Directive> {
let mut booked: Vec<Directive> = self.directives.iter().map(|s| s.value.clone()).collect();
debug_assert!(
!booked.iter().any(|d| matches!(d, Directive::Transaction(t) if rustledger_booking::is_synthesized_pad(t))),
"balance_view called on a Ledger whose directives already contain synth pad transactions",
);
let pad_result = rustledger_booking::process_pads(&booked);
let mut merged: Vec<Directive> =
Vec::with_capacity(booked.len() + pad_result.padding_transactions.len());
for txn in pad_result.padding_transactions {
merged.push(Directive::Transaction(txn));
}
merged.append(&mut booked);
merged.sort_by_key(rustledger_core::Directive::date);
merged
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct LedgerError {
pub severity: ErrorSeverity,
pub code: String,
pub message: String,
pub location: Option<ErrorLocation>,
pub source_span: Option<(usize, usize)>,
pub file_id: Option<u16>,
pub phase: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorSeverity {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub struct ErrorLocation {
pub file: std::path::PathBuf,
pub line: usize,
pub column: usize,
}
impl LedgerError {
pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
severity: ErrorSeverity::Error,
code: code.into(),
message: message.into(),
location: None,
source_span: None,
file_id: None,
phase: "validate".to_string(),
}
}
pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
severity: ErrorSeverity::Warning,
code: code.into(),
message: message.into(),
location: None,
source_span: None,
file_id: None,
phase: "validate".to_string(),
}
}
#[must_use]
pub const fn with_source_span(mut self, span: (usize, usize), file_id: u16) -> Self {
self.source_span = Some(span);
self.file_id = Some(file_id);
self
}
#[must_use]
pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
self.phase = phase.into();
self
}
#[must_use]
pub fn with_location(mut self, location: ErrorLocation) -> Self {
self.location = Some(location);
self
}
}
pub fn process(raw: LoadResult, options: &LoadOptions) -> Result<Ledger, ProcessError> {
let mut errors: Vec<LedgerError> = Vec::new();
for load_err in &raw.errors {
errors.push(LedgerError::error("LOAD", load_err.to_string()).with_phase("parse"));
}
#[cfg(any(feature = "validation", feature = "booking"))]
let effective_booking_method = resolve_effective_booking_method(&raw, options);
#[cfg(feature = "validation")]
let validation_session = if options.validate {
Some(rustledger_validate::ValidationSession::new(
build_validation_options(&raw.options, &raw.source_map, effective_booking_method),
))
} else {
None
};
#[cfg(feature = "validation")]
let today = jiff::Zoned::now().date();
let synthed = crate::Directives::<crate::Raw>::from_parser(raw.directives)
.sort()
.apply_synth_plugins(
&raw.plugins,
&raw.options,
options,
&raw.source_map,
&mut errors,
)?;
#[cfg(feature = "validation")]
let (directives, validation_session) =
synthed.early_validate(validation_session, today, &raw.source_map, &mut errors);
#[cfg(not(feature = "validation"))]
let directives = synthed.early_validate(&raw.source_map, &mut errors);
let (booked, failed) = directives.book(
#[cfg(feature = "booking")]
effective_booking_method,
#[cfg(feature = "booking")]
&mut errors,
);
let regular_applied = booked.apply_regular_plugins(
&raw.plugins,
&raw.options,
options,
&raw.source_map,
&mut errors,
)?;
#[cfg(feature = "validation")]
let late_validated =
regular_applied.late_validate(validation_session, today, &raw.source_map, &mut errors);
#[cfg(not(feature = "validation"))]
let late_validated = regular_applied.late_validate(&raw.source_map, &mut errors);
let finalized = late_validated.finalize(failed);
Ok(Ledger {
directives: finalized.into_inner(),
options: raw.options,
plugins: raw.plugins,
source_map: raw.source_map,
errors,
display_context: raw.display_context,
})
}
#[cfg(any(feature = "validation", feature = "booking"))]
fn resolve_effective_booking_method(
raw: &LoadResult,
options: &LoadOptions,
) -> rustledger_core::BookingMethod {
let file_set = raw.options.set_options.contains("booking_method");
if file_set {
raw.options
.booking_method
.parse()
.unwrap_or(options.booking_method)
} else {
options.booking_method
}
}
type CanonicalSortKey = (
rustledger_core::NaiveDate,
rustledger_core::DirectivePriority,
u16,
usize,
);
#[inline]
const fn canonical_sort_key(d: &Spanned<Directive>) -> CanonicalSortKey {
(d.value.date(), d.value.priority(), d.file_id, d.span.start)
}
impl crate::Directives<crate::Raw> {
#[must_use]
pub(crate) fn sort(mut self) -> crate::Directives<crate::Sorted> {
self.as_vec_mut().sort_by_key(canonical_sort_key);
crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
}
}
impl crate::Directives<crate::Sorted> {
pub(crate) fn apply_synth_plugins(
mut self,
plugins: &[crate::Plugin],
file_options: &crate::Options,
options: &LoadOptions,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> Result<crate::Directives<crate::Synthed>, ProcessError> {
#[cfg(feature = "plugins")]
run_plugins(
self.as_vec_mut(),
plugins,
file_options,
options,
source_map,
errors,
PluginPass::PreBookingSynth,
)?;
#[cfg(not(feature = "plugins"))]
{
let _ = (plugins, file_options, options, source_map, errors);
}
Ok(crate::Directives::new_unchecked(std::mem::take(
self.as_vec_mut(),
)))
}
}
impl crate::Directives<crate::Synthed> {
#[cfg(feature = "validation")]
pub(crate) fn early_validate(
mut self,
validation_session: Option<
rustledger_validate::ValidationSession<rustledger_validate::Pending>,
>,
today: rustledger_core::NaiveDate,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> (
crate::Directives<crate::EarlyValidated>,
Option<rustledger_validate::ValidationSession<rustledger_validate::EarlyDone>>,
) {
let session_out = validation_session.map(|session| {
let (session, phase_errors) = session.run_early_spanned(self.as_slice(), today);
ledger_errors_extend(errors, phase_errors, source_map);
session
});
(
crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut())),
session_out,
)
}
#[cfg(not(feature = "validation"))]
pub(crate) fn early_validate(
mut self,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> crate::Directives<crate::EarlyValidated> {
let _ = (source_map, errors);
crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
}
}
impl crate::Directives<crate::EarlyValidated> {
pub(crate) fn book(
mut self,
#[cfg(feature = "booking")] effective_method: rustledger_core::BookingMethod,
#[cfg(feature = "booking")] errors: &mut Vec<LedgerError>,
) -> (
crate::Directives<crate::Booked>,
crate::phase::FailedBookings,
) {
#[cfg(feature = "booking")]
let (booked, failed) =
run_booking(std::mem::take(self.as_vec_mut()), effective_method, errors);
#[cfg(not(feature = "booking"))]
let (booked, failed): (Vec<Spanned<Directive>>, Vec<Spanned<Directive>>) =
(std::mem::take(self.as_vec_mut()), Vec::new());
(
crate::Directives::new_unchecked(booked),
crate::phase::FailedBookings::new(failed),
)
}
}
impl crate::Directives<crate::Booked> {
pub(crate) fn apply_regular_plugins(
mut self,
plugins: &[crate::Plugin],
file_options: &crate::Options,
options: &LoadOptions,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> Result<crate::Directives<crate::RegularPluginsApplied>, ProcessError> {
#[cfg(feature = "plugins")]
run_plugins(
self.as_vec_mut(),
plugins,
file_options,
options,
source_map,
errors,
PluginPass::PostBooking,
)?;
#[cfg(not(feature = "plugins"))]
{
let _ = (plugins, file_options, options, source_map, errors);
}
Ok(crate::Directives::new_unchecked(std::mem::take(
self.as_vec_mut(),
)))
}
}
impl crate::Directives<crate::RegularPluginsApplied> {
#[cfg(feature = "validation")]
pub(crate) fn late_validate(
mut self,
validation_session: Option<
rustledger_validate::ValidationSession<rustledger_validate::EarlyDone>,
>,
today: rustledger_core::NaiveDate,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> crate::Directives<crate::LateValidated> {
if let Some(session) = validation_session {
let (session, phase_errors) = session.run_late_spanned(self.as_slice(), today);
ledger_errors_extend(errors, phase_errors, source_map);
let finalize_errors = session.finalize();
ledger_errors_extend(errors, finalize_errors, source_map);
}
crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
}
#[cfg(not(feature = "validation"))]
pub(crate) fn late_validate(
mut self,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
) -> crate::Directives<crate::LateValidated> {
let _ = (source_map, errors);
crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
}
}
impl crate::Directives<crate::LateValidated> {
pub(crate) fn finalize(
mut self,
failed: crate::phase::FailedBookings,
) -> crate::Directives<crate::Finalized> {
let mut v = std::mem::take(self.as_vec_mut());
v.extend(failed.into_inner());
v.sort_by_key(canonical_sort_key);
#[cfg(feature = "booking")]
for spanned in &mut v {
if let Directive::Transaction(txn) = &mut spanned.value {
rustledger_booking::normalize_prices(txn);
}
}
crate::Directives::new_unchecked(v)
}
}
#[cfg(feature = "booking")]
fn run_booking(
mut directives: Vec<Spanned<Directive>>,
booking_method: BookingMethod,
errors: &mut Vec<LedgerError>,
) -> (Vec<Spanned<Directive>>, Vec<Spanned<Directive>>) {
use rustledger_booking::BookingEngine;
let mut engine = BookingEngine::with_method(booking_method);
engine.register_account_methods(directives.iter().map(|s| &s.value));
let mut order: Vec<usize> = (0..directives.len()).collect();
order.sort_by_key(|&i| rustledger_core::booking_sort_key(&directives[i].value));
let mut failed_indices: Vec<usize> = Vec::new();
for &i in &order {
let spanned = &mut directives[i];
if let Directive::Transaction(txn) = &mut spanned.value {
match engine.book_and_interpolate(txn) {
Ok(result) => {
engine.apply(&result.transaction);
*txn = result.transaction;
}
Err(e) => {
errors.push(LedgerError::error(
"BOOK",
format!("{} ({}, \"{}\")", e, txn.date, txn.narration),
));
failed_indices.push(i);
}
}
}
}
let failed_set: rustc_hash::FxHashSet<usize> = failed_indices.iter().copied().collect();
let mut booked = Vec::with_capacity(directives.len() - failed_indices.len());
let mut failed = Vec::with_capacity(failed_indices.len());
for (i, d) in directives.into_iter().enumerate() {
if failed_set.contains(&i) {
failed.push(d);
} else {
booked.push(d);
}
}
(booked, failed)
}
#[cfg(feature = "plugins")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginPass {
PreBookingSynth,
PostBooking,
}
#[cfg(feature = "plugins")]
struct PluginInvocation {
name: String,
config: Option<String>,
force_python: bool,
}
#[cfg(feature = "plugins")]
pub fn run_plugins(
directives: &mut Vec<Spanned<Directive>>,
file_plugins: &[Plugin],
file_options: &Options,
options: &LoadOptions,
source_map: &SourceMap,
errors: &mut Vec<LedgerError>,
pass: PluginPass,
) -> Result<(), ProcessError> {
use rustledger_plugin::{NativePluginRegistry, PluginOptions};
let base_dir = source_map
.files()
.first()
.and_then(|f| f.path.parent())
.unwrap_or_else(|| std::path::Path::new("."));
let registry = NativePluginRegistry::global();
let mut entries: Vec<PluginInvocation> = Vec::new();
if matches!(pass, PluginPass::PreBookingSynth) {
if options.auto_accounts {
entries.push(PluginInvocation {
name: rustledger_plugin::AUTO_ACCOUNTS_NAME.to_string(),
config: None,
force_python: false,
});
}
if options.run_plugins && !file_options.documents.is_empty() {
let resolved: Vec<String> = file_options
.documents
.iter()
.map(|d| {
let path = std::path::Path::new(d);
if path.is_absolute() {
d.clone()
} else {
base_dir.join(path).to_string_lossy().to_string()
}
})
.collect();
entries.push(PluginInvocation {
name: rustledger_plugin::DOCUMENT_DISCOVERY_NAME.to_string(),
config: Some(rustledger_plugin::document_discovery_config(
base_dir, &resolved,
)),
force_python: false,
});
}
}
let want_synth = matches!(pass, PluginPass::PreBookingSynth);
if options.run_plugins {
for plugin in file_plugins {
if registry.find_synth(&plugin.name).is_some() == want_synth {
entries.push(PluginInvocation {
name: plugin.name.clone(),
config: plugin.config.clone(),
force_python: plugin.force_python,
});
}
}
}
for extra in &options.extra_plugins {
if registry.find_synth(&extra.name).is_some() == want_synth {
entries.push(PluginInvocation {
name: extra.name.clone(),
config: extra.config.clone(),
force_python: false,
});
}
}
if entries.is_empty() {
return Ok(());
}
let plugin_options = PluginOptions {
operating_currencies: file_options.operating_currency.clone(),
title: file_options.title.clone(),
};
let pass_kind = match pass {
PluginPass::PreBookingSynth => rustledger_plugin::PluginPass::Synth,
PluginPass::PostBooking => rustledger_plugin::PluginPass::Regular,
};
for invocation in &entries {
let resolved = match rustledger_plugin::resolve_plugin(
&invocation.name,
invocation.force_python,
pass_kind,
registry,
base_dir,
options.path_security,
) {
Ok(resolved) => resolved,
Err(e) => {
errors.push(resolve_error_to_ledger(&e));
continue;
}
};
let wrappers = build_wrappers(directives, source_map);
match resolved.run(wrappers, &plugin_options, &invocation.config, base_dir) {
Ok(output) => {
record_plugin_errors(errors, output.errors, source_map);
apply_plugin_ops(directives, output.ops, errors, source_map)?;
}
Err(e) => errors.push(run_error_to_ledger(&e)),
}
}
Ok(())
}
#[cfg(feature = "plugins")]
fn build_wrappers(
directives: &[Spanned<Directive>],
source_map: &SourceMap,
) -> Vec<rustledger_plugin::DirectiveWrapper> {
use rustledger_plugin::directive_to_wrapper_with_location;
directives
.iter()
.map(|spanned| {
let (filename, lineno) = if let Some(file) = source_map.get(spanned.file_id as usize) {
let (line, _col) = file.line_col(spanned.span.start);
(Some(file.path.display().to_string()), Some(line as u32))
} else {
(None, None)
};
directive_to_wrapper_with_location(&spanned.value, filename, lineno)
})
.collect()
}
#[cfg(feature = "plugins")]
fn record_plugin_errors(
errors: &mut Vec<LedgerError>,
plugin_errors: Vec<rustledger_plugin::PluginError>,
source_map: &SourceMap,
) {
for err in plugin_errors {
let mut ledger_err = match err.severity {
rustledger_plugin::PluginErrorSeverity::Error => {
LedgerError::error("PLUGIN", err.message).with_phase("plugin")
}
rustledger_plugin::PluginErrorSeverity::Warning => {
LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
}
};
if let (Some(file), Some(line)) = (&err.source_file, err.line_number) {
let resolved_path = source_map
.get_by_path(std::path::Path::new(file))
.map_or_else(|| std::path::PathBuf::from(file), |f| f.path.clone());
ledger_err = ledger_err.with_location(ErrorLocation {
file: resolved_path,
line: line as usize,
column: 1,
});
}
errors.push(ledger_err);
}
}
#[cfg(feature = "plugins")]
fn apply_plugin_ops(
directives: &mut Vec<Spanned<Directive>>,
ops: Vec<rustledger_plugin::PluginOp>,
errors: &mut Vec<LedgerError>,
source_map: &SourceMap,
) -> Result<(), ProcessError> {
use rustledger_plugin::PluginOp;
use rustledger_plugin::wrapper_to_directive;
if let Err(msg) = rustledger_plugin::validate_op_coverage(directives.len(), &ops) {
errors.push(LedgerError::error("PLUGIN", msg).with_phase("plugin"));
return Ok(());
}
let mut new_directives = Vec::with_capacity(ops.len());
for op in ops {
match op {
PluginOp::Keep(i) => {
new_directives.push(directives[i].clone());
}
PluginOp::Modify(i, wrapper) => {
let mut directive = wrapper_to_directive(&wrapper)
.map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
sanitize_inner_posting_spans(&mut directive, source_map);
new_directives.push(Spanned {
value: directive,
span: directives[i].span,
file_id: directives[i].file_id,
});
}
PluginOp::Insert(wrapper) => {
let (span, file_id) = match (&wrapper.filename, wrapper.lineno) {
(Some(filename), Some(lineno)) => {
if let Some(file) = source_map.get_by_path(std::path::Path::new(filename)) {
let span_start = file.line_start(lineno as usize).unwrap_or(0);
(
rustledger_parser::Span::new(span_start, span_start),
file.id as u16,
)
} else {
(
rustledger_parser::Span::ZERO,
rustledger_parser::SYNTHESIZED_FILE_ID,
)
}
}
_ => (
rustledger_parser::Span::ZERO,
rustledger_parser::SYNTHESIZED_FILE_ID,
),
};
let mut directive = wrapper_to_directive(&wrapper)
.map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
sanitize_inner_posting_spans(&mut directive, source_map);
new_directives.push(Spanned::new(directive, span).with_file_id(file_id as usize));
}
PluginOp::Delete(_) => {}
}
}
*directives = new_directives;
Ok(())
}
#[cfg(feature = "plugins")]
fn sanitize_inner_posting_spans(directive: &mut Directive, source_map: &SourceMap) {
use rustledger_core::Span;
use rustledger_parser::SYNTHESIZED_FILE_ID;
if let Directive::Transaction(txn) = directive {
for p in &mut txn.postings {
let ok = if p.file_id == SYNTHESIZED_FILE_ID {
true
} else {
source_map
.get(p.file_id as usize)
.is_some_and(|f| p.span.start <= p.span.end && p.span.end <= f.source.len())
};
if !ok {
let inner = std::mem::replace(
&mut p.value,
rustledger_core::Posting::auto(rustledger_core::InternedStr::from("")),
);
*p = rustledger_core::Spanned::synthesized(inner);
} else if p.file_id == SYNTHESIZED_FILE_ID && p.span != Span::ZERO {
p.span = Span::ZERO;
}
}
}
}
#[cfg(feature = "validation")]
#[must_use]
pub fn validation_options_from_options(
options: &Options,
) -> rustledger_validate::ValidationOptions {
rustledger_validate::ValidationOptions::default()
.with_account_types(
options
.account_types()
.iter()
.map(|s| (*s).to_string())
.collect(),
)
.with_infer_tolerance_from_cost(options.infer_tolerance_from_cost)
.with_tolerance_multiplier(options.inferred_tolerance_multiplier)
.with_inferred_tolerance_default(options.inferred_tolerance_default.clone())
.with_default_booking_method(
options
.booking_method
.parse()
.unwrap_or(BookingMethod::Strict),
)
}
#[cfg(feature = "validation")]
#[must_use]
pub fn resolve_document_dirs(
documents: &[String],
base_dir: Option<&std::path::Path>,
) -> Vec<std::path::PathBuf> {
documents
.iter()
.map(|d| {
let path = std::path::Path::new(d);
if path.is_absolute() {
path.to_path_buf()
} else if let Some(base) = base_dir {
base.join(path)
} else {
path.to_path_buf()
}
})
.collect()
}
#[cfg(feature = "validation")]
#[must_use]
pub fn document_source_dirs(source_map: &SourceMap) -> Vec<std::path::PathBuf> {
source_map
.files()
.iter()
.map(|f| {
f.path.parent().map_or_else(
|| std::path::PathBuf::from("."),
std::path::Path::to_path_buf,
)
})
.collect()
}
#[cfg(feature = "validation")]
fn build_validation_options(
file_options: &Options,
source_map: &SourceMap,
default_booking_method: BookingMethod,
) -> rustledger_validate::ValidationOptions {
let base_dir = source_map
.files()
.first()
.and_then(|f| f.path.parent())
.unwrap_or_else(|| std::path::Path::new("."));
validation_options_from_options(file_options)
.with_document_dirs(resolve_document_dirs(
&file_options.documents,
Some(base_dir),
))
.with_document_source_dirs(document_source_dirs(source_map))
.with_default_booking_method(default_booking_method)
}
#[cfg(feature = "validation")]
fn ledger_errors_extend(
errors: &mut Vec<LedgerError>,
validation_errors: Vec<rustledger_validate::ValidationError>,
source_map: &SourceMap,
) {
for err in validation_errors {
let phase = if err.code.is_parse_phase() {
"parse"
} else {
"validate"
};
let severity_level = if err.code.is_warning() {
ErrorSeverity::Warning
} else {
ErrorSeverity::Error
};
let message = match &err.note {
Some(note) => format!("{err}\n note: {note}"),
None => err.to_string(),
};
let location = err.span.and_then(|span| {
let fid = err.file_id? as usize;
let file = source_map.get(fid)?;
let (line, column) = file.line_col(span.start);
Some(ErrorLocation {
file: file.path.clone(),
line,
column,
})
});
errors.push(LedgerError {
severity: severity_level,
code: err.code.code().to_string(),
message,
location,
source_span: err.span.map(|s| (s.start, s.end)),
file_id: err.file_id,
phase: phase.to_string(),
});
}
}
pub fn load(path: &Path, options: &LoadOptions) -> Result<Ledger, ProcessError> {
let mut loader = crate::Loader::new();
if options.path_security {
loader = loader.with_path_security(true);
}
let raw = loader.load(path)?;
process(raw, options)
}
pub fn load_with_fs(
path: &Path,
options: &LoadOptions,
fs: Box<dyn crate::FileSystem>,
) -> Result<Ledger, ProcessError> {
let mut loader = crate::Loader::new().with_filesystem(fs);
if options.path_security {
loader = loader.with_path_security(true);
}
let raw = loader.load(path)?;
process(raw, options)
}
pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
crate::Loader::new().load(path)
}
#[cfg(feature = "plugins")]
fn module_ref_message(raw_name: &str, file: Option<&str>) -> String {
match file {
Some(path) => format!(
"Python plugin \"{raw_name}\" is not supported by module name; \
reference the file directly: plugin \"{path}\""
),
None => format!(
"Python plugin \"{raw_name}\" is not supported by module name; \
reference the file directly, e.g. plugin \"/path/to/plugin.py\". \
The plugin sandbox cannot see the host venv, so the plugin must be \
self-contained (stdlib plus the beancount compat shim)."
),
}
}
#[cfg(feature = "plugins")]
fn resolve_error_to_ledger(e: &rustledger_plugin::PluginResolveError) -> LedgerError {
use rustledger_plugin::PluginResolveError as Re;
match e {
Re::PathOutsideBase { name } => LedgerError::error(
"PLUGIN",
format!("plugin path '{name}' is outside the ledger directory"),
)
.with_phase("plugin"),
Re::WasmFeatureDisabled { name } => LedgerError::error(
"PLUGIN",
format!("WASM plugin '{name}' requires the wasm-plugins feature"),
)
.with_phase("plugin"),
Re::PythonFeatureDisabled { name } => LedgerError::error(
"E8005",
format!("Python plugin \"{name}\" requires the python-plugins feature"),
)
.with_phase("plugin"),
Re::PythonModuleName {
name,
suggested_file,
} => LedgerError::error("E8004", module_ref_message(name, suggested_file.as_deref()))
.with_phase("plugin"),
Re::NotFound {
name,
suggested_file,
} => match suggested_file {
Some(path) => LedgerError::error("E8004", module_ref_message(name, Some(path)))
.with_phase("plugin"),
None => LedgerError::error("E8001", format!("Plugin not found: \"{name}\""))
.with_phase("plugin"),
},
}
}
#[cfg(feature = "plugins")]
fn run_error_to_ledger(e: &rustledger_plugin::PluginRunError) -> LedgerError {
use rustledger_plugin::PluginRunError as Rn;
match e {
Rn::WasmFailed { path, message } => LedgerError::error(
"PLUGIN",
format!("WASM plugin {} failed: {message}", path.display()),
)
.with_phase("plugin"),
Rn::PythonFailed { message } => {
LedgerError::error("E8002", message.clone()).with_phase("plugin")
}
}
}
#[cfg(all(test, feature = "booking", feature = "validation"))]
mod finalize_price_tests {
use rustledger_core::{Directive, PriceKind};
#[test]
fn finalize_normalizes_total_at_at_price_to_per_unit() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("main.bean");
std::fs::write(
&path,
"2024-01-01 open Assets:Cash USD\n\
2024-01-01 open Assets:Other EUR\n\
2024-01-02 * \"total price\"\n \
Assets:Cash 7 USD @@ 10 EUR\n \
Assets:Other -10 EUR\n",
)
.unwrap();
let ledger =
super::load(&path, &super::LoadOptions::default()).expect("ledger should load");
let price = ledger
.directives
.iter()
.find_map(|s| match &s.value {
Directive::Transaction(t) => t.postings.iter().find_map(|p| p.price.as_ref()),
_ => None,
})
.expect("the `@@` posting should carry a price");
assert_eq!(
price.kind,
PriceKind::Unit,
"`@@` must be normalized to a per-unit price, not left as a total"
);
let amount = price
.amount
.as_ref()
.and_then(|a| a.as_amount())
.expect("normalized per-unit amount present");
assert!(
amount.number.to_string().starts_with("1.42857"),
"per-unit price should be 10/7, got {}",
amount.number
);
}
}
#[cfg(all(test, feature = "validation"))]
mod validation_options_tests {
use super::validation_options_from_options;
use crate::Options;
use rust_decimal_macros::dec;
#[test]
fn maps_inferred_tolerance_default_and_account_types() {
let mut opts = Options::new();
opts.set("inferred_tolerance_default", "CLP:0.5");
opts.set("name_assets", "Activos");
let vo = validation_options_from_options(&opts);
assert_eq!(vo.inferred_tolerance_default.get("CLP"), Some(&dec!(0.5)));
assert_eq!(vo.account_types[0], "Activos");
}
}
#[cfg(all(test, feature = "plugins"))]
mod sanitize_tests {
use super::sanitize_inner_posting_spans;
use crate::source_map::SourceMap;
use rust_decimal_macros::dec;
use rustledger_core::{
Amount, Directive, IncompleteAmount, Posting, SYNTHESIZED_FILE_ID, Span, Spanned,
Transaction,
};
use std::path::PathBuf;
use std::sync::Arc;
fn txn_with_postings(postings: Vec<Spanned<Posting>>) -> Directive {
let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
let mut txn = Transaction::new(date, "x");
txn.postings = postings;
Directive::Transaction(txn)
}
fn posting_at(file_id: u16, span: Span) -> Spanned<Posting> {
let p = Posting::with_incomplete(
"Assets:Cash",
IncompleteAmount::Complete(Amount::new(dec!(1), "USD")),
);
Spanned::new(p, span).with_file_id(file_id as usize)
}
fn source_map_with_one_file(source: &str) -> (SourceMap, u16) {
let mut sm = SourceMap::new();
let id = sm.add_file(PathBuf::from("test.bean"), Arc::from(source));
(sm, id as u16)
}
#[test]
fn span_within_real_file_is_preserved() {
let (sm, fid) = source_map_with_one_file("0123456789");
let mut d = txn_with_postings(vec![posting_at(fid, Span::new(2, 6))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, fid);
assert_eq!(t.postings[0].span, Span::new(2, 6));
}
#[test]
fn span_past_eof_is_reset_to_synthesized() {
let (sm, fid) = source_map_with_one_file("0123456789"); let mut d = txn_with_postings(vec![posting_at(fid, Span::new(0, 9999))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
assert_eq!(t.postings[0].span, Span::ZERO);
}
#[test]
fn unknown_file_id_is_reset_to_synthesized() {
let (sm, _real) = source_map_with_one_file("hello");
let mut d = txn_with_postings(vec![posting_at(123, Span::new(0, 5))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
assert_eq!(t.postings[0].span, Span::ZERO);
}
#[test]
fn start_after_end_is_reset_to_synthesized() {
let (sm, fid) = source_map_with_one_file("abcdef");
let mut d = txn_with_postings(vec![posting_at(fid, Span::new(5, 2))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
assert_eq!(t.postings[0].span, Span::ZERO);
}
#[test]
fn synthesized_file_id_is_left_alone_but_span_normalized() {
let (sm, _fid) = source_map_with_one_file("x");
let mut d = txn_with_postings(vec![posting_at(SYNTHESIZED_FILE_ID, Span::new(100, 200))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
assert_eq!(t.postings[0].span, Span::ZERO, "synth span normalized");
}
#[test]
fn boundary_span_eq_source_len_is_valid() {
let (sm, fid) = source_map_with_one_file("abcd");
let mut d = txn_with_postings(vec![posting_at(fid, Span::new(0, 4))]);
sanitize_inner_posting_spans(&mut d, &sm);
let Directive::Transaction(t) = &d else {
unreachable!()
};
assert_eq!(t.postings[0].file_id, fid);
assert_eq!(t.postings[0].span, Span::new(0, 4));
}
#[test]
fn non_transaction_directive_is_left_alone() {
let (sm, _fid) = source_map_with_one_file("x");
let mut d = Directive::Open(rustledger_core::Open {
date: rustledger_core::naive_date(2024, 1, 1).unwrap(),
account: "Assets:Bank".into(),
currencies: vec![],
booking: None,
meta: Default::default(),
});
sanitize_inner_posting_spans(&mut d, &sm); assert!(matches!(d, Directive::Open(_)));
}
}
#[cfg(all(test, feature = "plugins"))]
mod module_ref_message_tests {
use super::module_ref_message;
#[test]
fn message_uses_resolved_path_when_known() {
let msg = module_ref_message("pkg.mod", Some("/abs/pkg/mod.py"));
assert!(msg.contains("is not supported by module name"));
assert!(msg.contains("plugin \"/abs/pkg/mod.py\""));
}
#[test]
fn message_falls_back_to_guidance_when_unresolved() {
let msg = module_ref_message("pkg.mod", None);
assert!(msg.contains("reference the file directly"));
assert!(msg.contains("self-contained"));
}
}