#![forbid(unsafe_code)]
#![warn(missing_docs)]
#[cfg(feature = "cache")]
pub mod cache;
mod options;
#[cfg(any(feature = "booking", feature = "plugins", feature = "validation"))]
mod process;
mod source_map;
mod vfs;
#[cfg(feature = "cache")]
pub use cache::{
CacheEntry, CachedOptions, CachedPlugin, invalidate_cache, load_cache_entry,
reintern_directives, reintern_plain_directives, save_cache_entry,
};
pub use options::Options;
pub use source_map::{SourceFile, SourceMap};
pub use vfs::{DiskFileSystem, FileSystem, VirtualFileSystem};
#[cfg(feature = "plugins")]
pub use process::run_plugins;
#[cfg(any(feature = "booking", feature = "plugins", feature = "validation"))]
pub use process::{
ErrorLocation, ErrorSeverity, Ledger, LedgerError, LoadOptions, ProcessError, load, load_raw,
process,
};
use rustledger_core::{Directive, DisplayContext};
use rustledger_parser::{ParseError, Span, Spanned};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use thiserror::Error;
fn normalize_path(path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
if path.is_absolute() {
path.to_path_buf()
} else if let Ok(cwd) = std::env::current_dir() {
let mut result = cwd;
for component in path.components() {
match component {
std::path::Component::ParentDir => {
result.pop();
}
std::path::Component::Normal(s) => {
result.push(s);
}
std::path::Component::CurDir => {}
std::path::Component::RootDir => {
result = PathBuf::from("/");
}
std::path::Component::Prefix(p) => {
result = PathBuf::from(p.as_os_str());
}
}
}
result
} else {
path.to_path_buf()
}
}
#[derive(Debug, Error)]
pub enum LoadError {
#[error("failed to read file {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"Duplicate filename parsed: \"{}\" (include cycle: {})",
.cycle.last().map_or("", String::as_str),
.cycle.join(" -> ")
)]
IncludeCycle {
cycle: Vec<String>,
},
#[error("parse errors in {path}")]
ParseErrors {
path: PathBuf,
errors: Vec<ParseError>,
},
#[error("path traversal not allowed: {include_path} escapes base directory {base_dir}")]
PathTraversal {
include_path: String,
base_dir: PathBuf,
},
#[error("failed to decrypt {path}: {message}")]
Decryption {
path: PathBuf,
message: String,
},
#[error("include pattern \"{pattern}\" does not match any files")]
GlobNoMatch {
pattern: String,
},
#[error("failed to expand include pattern \"{pattern}\": {message}")]
GlobError {
pattern: String,
message: String,
},
}
#[derive(Debug)]
pub struct LoadResult {
pub directives: Vec<Spanned<Directive>>,
pub options: Options,
pub plugins: Vec<Plugin>,
pub source_map: SourceMap,
pub errors: Vec<LoadError>,
pub display_context: DisplayContext,
}
#[derive(Debug, Clone)]
pub struct Plugin {
pub name: String,
pub config: Option<String>,
pub span: Span,
pub file_id: usize,
pub force_python: bool,
}
fn decrypt_gpg_file(path: &Path) -> Result<String, LoadError> {
let output = Command::new("gpg")
.args(["--batch", "--decrypt"])
.arg(path)
.output()
.map_err(|e| LoadError::Decryption {
path: path.to_path_buf(),
message: format!("failed to run gpg: {e}"),
})?;
if !output.status.success() {
return Err(LoadError::Decryption {
path: path.to_path_buf(),
message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
String::from_utf8(output.stdout).map_err(|e| LoadError::Decryption {
path: path.to_path_buf(),
message: format!("decrypted content is not valid UTF-8: {e}"),
})
}
#[derive(Debug)]
pub struct Loader {
loaded_files: HashSet<PathBuf>,
include_stack: Vec<PathBuf>,
include_stack_set: HashSet<PathBuf>,
root_dir: Option<PathBuf>,
enforce_path_security: bool,
fs: Box<dyn FileSystem>,
}
impl Default for Loader {
fn default() -> Self {
Self {
loaded_files: HashSet::new(),
include_stack: Vec::new(),
include_stack_set: HashSet::new(),
root_dir: None,
enforce_path_security: false,
fs: Box::new(DiskFileSystem),
}
}
}
impl Loader {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn with_path_security(mut self, enabled: bool) -> Self {
self.enforce_path_security = enabled;
self
}
#[must_use]
pub fn with_root_dir(mut self, root: PathBuf) -> Self {
self.root_dir = Some(root);
self.enforce_path_security = true;
self
}
#[must_use]
pub fn with_filesystem(mut self, fs: Box<dyn FileSystem>) -> Self {
self.fs = fs;
self
}
pub fn load(&mut self, path: &Path) -> Result<LoadResult, LoadError> {
let mut directives = Vec::new();
let mut options = Options::default();
let mut plugins = Vec::new();
let mut source_map = SourceMap::new();
let mut errors = Vec::new();
let canonical = self.fs.normalize(path);
if self.enforce_path_security && self.root_dir.is_none() {
self.root_dir = canonical.parent().map(Path::to_path_buf);
}
self.load_recursive(
&canonical,
&mut directives,
&mut options,
&mut plugins,
&mut source_map,
&mut errors,
)?;
let display_context = build_display_context(&directives, &options);
Ok(LoadResult {
directives,
options,
plugins,
source_map,
errors,
display_context,
})
}
fn load_recursive(
&mut self,
path: &Path,
directives: &mut Vec<Spanned<Directive>>,
options: &mut Options,
plugins: &mut Vec<Plugin>,
source_map: &mut SourceMap,
errors: &mut Vec<LoadError>,
) -> Result<(), LoadError> {
let path_buf = path.to_path_buf();
if self.include_stack_set.contains(&path_buf) {
let cycle: Vec<String> = self
.include_stack
.iter()
.map(|p| p.display().to_string())
.chain(std::iter::once(path.display().to_string()))
.collect();
return Err(LoadError::IncludeCycle { cycle });
}
if self.loaded_files.contains(&path_buf) {
return Ok(());
}
let source: std::sync::Arc<str> = if self.fs.is_encrypted(path) {
decrypt_gpg_file(path)?.into()
} else {
self.fs.read(path)?
};
let file_id = source_map.add_file(path_buf.clone(), std::sync::Arc::clone(&source));
self.include_stack_set.insert(path_buf.clone());
self.include_stack.push(path_buf.clone());
self.loaded_files.insert(path_buf);
let result = rustledger_parser::parse(&source);
if !result.errors.is_empty() {
errors.push(LoadError::ParseErrors {
path: path.to_path_buf(),
errors: result.errors,
});
}
for (key, value, _span) in result.options {
options.set(&key, &value);
}
for (name, config, span) in result.plugins {
let (actual_name, force_python) = if let Some(stripped) = name.strip_prefix("python:") {
(stripped.to_string(), true)
} else {
(name, false)
};
plugins.push(Plugin {
name: actual_name,
config,
span,
file_id,
force_python,
});
}
let base_dir = path.parent().unwrap_or(Path::new("."));
for (include_path, _span) in &result.includes {
let has_glob = include_path.contains('*')
|| include_path.contains('?')
|| include_path.contains('[');
let full_path = base_dir.join(include_path);
if self.enforce_path_security
&& let Some(ref root) = self.root_dir
{
let path_to_check = if has_glob {
let glob_start = include_path
.find(['*', '?', '['])
.unwrap_or(include_path.len());
let prefix = &include_path[..glob_start];
let prefix_path = if let Some(last_sep) = prefix.rfind('/') {
base_dir.join(&include_path[..=last_sep])
} else {
base_dir.to_path_buf()
};
normalize_path(&prefix_path)
} else {
normalize_path(&full_path)
};
if !path_to_check.starts_with(root) {
errors.push(LoadError::PathTraversal {
include_path: include_path.clone(),
base_dir: root.clone(),
});
continue;
}
}
let full_path_str = full_path.to_string_lossy();
let paths_to_load: Vec<PathBuf> = if has_glob {
match self.fs.glob(&full_path_str) {
Ok(matched) => matched,
Err(e) => {
errors.push(LoadError::GlobError {
pattern: include_path.clone(),
message: e,
});
continue;
}
}
} else {
vec![full_path.clone()]
};
if has_glob && paths_to_load.is_empty() {
errors.push(LoadError::GlobNoMatch {
pattern: include_path.clone(),
});
continue;
}
for matched_path in paths_to_load {
let canonical = self.fs.normalize(&matched_path);
if self.enforce_path_security
&& let Some(ref root) = self.root_dir
&& !canonical.starts_with(root)
{
errors.push(LoadError::PathTraversal {
include_path: matched_path.to_string_lossy().into_owned(),
base_dir: root.clone(),
});
continue;
}
if let Err(e) = self
.load_recursive(&canonical, directives, options, plugins, source_map, errors)
{
errors.push(e);
}
}
}
directives.extend(
result
.directives
.into_iter()
.map(|d| d.with_file_id(file_id)),
);
if let Some(popped) = self.include_stack.pop() {
self.include_stack_set.remove(&popped);
}
Ok(())
}
}
fn build_display_context(directives: &[Spanned<Directive>], options: &Options) -> DisplayContext {
let mut ctx = DisplayContext::new();
ctx.set_render_commas(options.render_commas);
for spanned in directives {
match &spanned.value {
Directive::Transaction(txn) => {
for posting in &txn.postings {
if let Some(ref units) = posting.units
&& let (Some(number), Some(currency)) = (units.number(), units.currency())
{
ctx.update(number, currency);
}
if let Some(ref cost) = posting.cost
&& let (Some(number), Some(currency)) =
(cost.number_per.or(cost.number_total), &cost.currency)
{
ctx.update(number, currency.as_str());
}
}
}
Directive::Balance(bal) => {
ctx.update(bal.amount.number, bal.amount.currency.as_str());
if let Some(tol) = bal.tolerance {
ctx.update(tol, bal.amount.currency.as_str());
}
}
Directive::Price(_) => {
}
Directive::Pad(_)
| Directive::Open(_)
| Directive::Close(_)
| Directive::Commodity(_)
| Directive::Event(_)
| Directive::Query(_)
| Directive::Note(_)
| Directive::Document(_)
| Directive::Custom(_) => {}
}
}
for (currency, precision) in &options.display_precision {
ctx.set_fixed_precision(currency, *precision);
}
ctx
}
#[cfg(not(any(feature = "booking", feature = "plugins", feature = "validation")))]
pub fn load(path: &Path) -> Result<LoadResult, LoadError> {
Loader::new().load(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_is_encrypted_file_gpg_extension() {
let fs = DiskFileSystem;
let path = Path::new("test.beancount.gpg");
assert!(fs.is_encrypted(path));
}
#[test]
fn test_is_encrypted_file_plain_beancount() {
let fs = DiskFileSystem;
let path = Path::new("test.beancount");
assert!(!fs.is_encrypted(path));
}
#[test]
fn test_is_encrypted_file_asc_with_pgp_header() {
let fs = DiskFileSystem;
let mut file = NamedTempFile::with_suffix(".asc").unwrap();
writeln!(file, "-----BEGIN PGP MESSAGE-----").unwrap();
writeln!(file, "some encrypted content").unwrap();
writeln!(file, "-----END PGP MESSAGE-----").unwrap();
file.flush().unwrap();
assert!(fs.is_encrypted(file.path()));
}
#[test]
fn test_is_encrypted_file_asc_without_pgp_header() {
let fs = DiskFileSystem;
let mut file = NamedTempFile::with_suffix(".asc").unwrap();
writeln!(file, "This is just a plain text file").unwrap();
writeln!(file, "with .asc extension but no PGP content").unwrap();
file.flush().unwrap();
assert!(!fs.is_encrypted(file.path()));
}
#[test]
fn test_decrypt_gpg_file_missing_gpg() {
let mut file = NamedTempFile::with_suffix(".gpg").unwrap();
writeln!(file, "fake encrypted content").unwrap();
file.flush().unwrap();
let result = decrypt_gpg_file(file.path());
assert!(result.is_err());
if let Err(LoadError::Decryption { path, message }) = result {
assert_eq!(path, file.path().to_path_buf());
assert!(!message.is_empty());
} else {
panic!("Expected Decryption error");
}
}
#[test]
fn test_plugin_force_python_prefix() {
let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
writeln!(file, r#"plugin "python:my_plugin""#).unwrap();
writeln!(file, r#"plugin "regular_plugin""#).unwrap();
file.flush().unwrap();
let result = Loader::new().load(file.path()).unwrap();
assert_eq!(result.plugins.len(), 2);
assert_eq!(result.plugins[0].name, "my_plugin");
assert!(result.plugins[0].force_python);
assert_eq!(result.plugins[1].name, "regular_plugin");
assert!(!result.plugins[1].force_python);
}
#[test]
fn test_plugin_force_python_with_config() {
let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
writeln!(file, r#"plugin "python:my_plugin" "config_value""#).unwrap();
file.flush().unwrap();
let result = Loader::new().load(file.path()).unwrap();
assert_eq!(result.plugins.len(), 1);
assert_eq!(result.plugins[0].name, "my_plugin");
assert!(result.plugins[0].force_python);
assert_eq!(result.plugins[0].config, Some("config_value".to_string()));
}
#[test]
fn test_virtual_filesystem_include_resolution() {
let mut vfs = VirtualFileSystem::new();
vfs.add_file(
"main.beancount",
r#"
include "accounts.beancount"
2024-01-15 * "Coffee"
Expenses:Food 5.00 USD
Assets:Bank -5.00 USD
"#,
);
vfs.add_file(
"accounts.beancount",
r"
2024-01-01 open Assets:Bank USD
2024-01-01 open Expenses:Food USD
",
);
let result = Loader::new()
.with_filesystem(Box::new(vfs))
.load(Path::new("main.beancount"))
.unwrap();
assert_eq!(result.directives.len(), 3);
assert!(result.errors.is_empty());
let directive_types: Vec<_> = result
.directives
.iter()
.map(|d| match &d.value {
rustledger_core::Directive::Open(_) => "open",
rustledger_core::Directive::Transaction(_) => "txn",
_ => "other",
})
.collect();
assert_eq!(directive_types, vec!["open", "open", "txn"]);
}
#[test]
fn test_virtual_filesystem_nested_includes() {
let mut vfs = VirtualFileSystem::new();
vfs.add_file("main.beancount", r#"include "level1.beancount""#);
vfs.add_file(
"level1.beancount",
r#"
include "level2.beancount"
2024-01-01 open Assets:Level1 USD
"#,
);
vfs.add_file("level2.beancount", "2024-01-01 open Assets:Level2 USD");
let result = Loader::new()
.with_filesystem(Box::new(vfs))
.load(Path::new("main.beancount"))
.unwrap();
assert_eq!(result.directives.len(), 2);
assert!(result.errors.is_empty());
}
#[test]
fn test_virtual_filesystem_missing_include() {
let mut vfs = VirtualFileSystem::new();
vfs.add_file("main.beancount", r#"include "nonexistent.beancount""#);
let result = Loader::new()
.with_filesystem(Box::new(vfs))
.load(Path::new("main.beancount"))
.unwrap();
assert!(!result.errors.is_empty());
let error_msg = result.errors[0].to_string();
assert!(error_msg.contains("not found") || error_msg.contains("Io"));
}
#[test]
fn test_virtual_filesystem_glob_include() {
let mut vfs = VirtualFileSystem::new();
vfs.add_file(
"main.beancount",
r#"
include "transactions/*.beancount"
2024-01-01 open Assets:Bank USD
"#,
);
vfs.add_file(
"transactions/2024.beancount",
r#"
2024-01-01 open Expenses:Food USD
2024-06-15 * "Groceries"
Expenses:Food 50.00 USD
Assets:Bank -50.00 USD
"#,
);
vfs.add_file(
"transactions/2025.beancount",
r#"
2025-01-01 open Expenses:Rent USD
2025-02-01 * "Rent"
Expenses:Rent 1000.00 USD
Assets:Bank -1000.00 USD
"#,
);
vfs.add_file(
"other/ignored.beancount",
"2024-01-01 open Expenses:Other USD",
);
let result = Loader::new()
.with_filesystem(Box::new(vfs))
.load(Path::new("main.beancount"))
.unwrap();
let opens = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
.count();
assert_eq!(
opens, 3,
"expected 3 open directives (1 main + 2 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!(
result.errors.is_empty(),
"expected no errors, got: {:?}",
result.errors
);
}
#[test]
fn test_virtual_filesystem_glob_dot_slash_prefix() {
let mut vfs = VirtualFileSystem::new();
vfs.add_file(
"main.beancount",
r#"
include "./transactions/*.beancount"
2024-01-01 open Assets:Bank USD
"#,
);
vfs.add_file(
"transactions/2024.beancount",
r#"
2024-01-01 open Expenses:Food USD
2024-06-15 * "Groceries"
Expenses:Food 50.00 USD
Assets:Bank -50.00 USD
"#,
);
vfs.add_file(
"transactions/2025.beancount",
r#"
2025-01-01 open Expenses:Rent USD
2025-02-01 * "Rent"
Expenses:Rent 1000.00 USD
Assets:Bank -1000.00 USD
"#,
);
let result = Loader::new()
.with_filesystem(Box::new(vfs))
.load(Path::new("main.beancount"))
.unwrap();
let opens = result
.directives
.iter()
.filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
.count();
assert_eq!(
opens, 3,
"expected 3 open directives (1 main + 2 transactions), ./ prefix should be normalized"
);
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 despite ./ prefix"
);
assert!(
result.errors.is_empty(),
"expected no errors, got: {:?}",
result.errors
);
}
#[test]
fn test_virtual_filesystem_glob_no_match() {
let mut vfs = VirtualFileSystem::new();
vfs.add_file("main.beancount", r#"include "nonexistent/*.beancount""#);
let result = Loader::new()
.with_filesystem(Box::new(vfs))
.load(Path::new("main.beancount"))
.unwrap();
let has_glob_error = result
.errors
.iter()
.any(|e| matches!(e, LoadError::GlobNoMatch { .. }));
assert!(
has_glob_error,
"expected GlobNoMatch error, got: {:?}",
result.errors
);
}
}