#![warn(missing_docs)]
#[cfg(feature = "guest")]
pub mod guest;
use serde::{Deserialize, Serialize};
pub const ABI_VERSION: u32 = 1;
pub const ABI_VERSION_EXPORT: &str = "__rustledger_abi_version";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInput {
pub directives: Vec<DirectiveWrapper>,
pub options: PluginOptions,
pub config: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginOutput {
pub ops: Vec<PluginOp>,
pub errors: Vec<PluginError>,
}
impl PluginOutput {
#[must_use]
pub fn passthrough(len: usize) -> Self {
Self {
ops: (0..len).map(PluginOp::Keep).collect(),
errors: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PluginOp {
Keep(usize),
Modify(usize, DirectiveWrapper),
Insert(DirectiveWrapper),
Delete(usize),
}
pub fn validate_op_coverage(n: usize, ops: &[PluginOp]) -> Result<(), String> {
let mut seen = vec![false; n];
for op in ops {
let idx = match op {
PluginOp::Keep(i) | PluginOp::Modify(i, _) | PluginOp::Delete(i) => Some(*i),
PluginOp::Insert(_) => None,
};
if let Some(i) = idx {
if i >= n {
return Err(format!(
"plugin op references out-of-bounds input index {i} (input has {n} directives)"
));
}
if seen[i] {
return Err(format!(
"plugin op references input index {i} more than once"
));
}
seen[i] = true;
}
}
for (i, was_seen) in seen.iter().enumerate() {
if !was_seen {
return Err(format!(
"plugin omitted input directive {i} (must appear in exactly one of Keep/Modify/Delete)"
));
}
}
Ok(())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginOptions {
pub operating_currencies: Vec<String>,
pub title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginError {
pub message: String,
pub source_file: Option<String>,
pub line_number: Option<u32>,
pub severity: PluginErrorSeverity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PluginErrorSeverity {
#[serde(rename = "warning")]
Warning,
#[serde(rename = "error")]
Error,
}
impl PluginError {
#[must_use]
pub fn error(message: impl Into<String>) -> Self {
Self {
message: message.into(),
source_file: None,
line_number: None,
severity: PluginErrorSeverity::Error,
}
}
#[must_use]
pub fn warning(message: impl Into<String>) -> Self {
Self {
message: message.into(),
source_file: None,
line_number: None,
severity: PluginErrorSeverity::Warning,
}
}
#[must_use]
pub fn at(mut self, file: impl Into<String>, line: u32) -> Self {
self.source_file = Some(file.into());
self.line_number = Some(line);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectiveWrapper {
#[serde(skip_serializing, default)]
pub directive_type: String,
pub date: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub lineno: Option<u32>,
#[serde(flatten)]
pub data: DirectiveData,
}
impl DirectiveWrapper {
#[must_use]
pub const fn type_sort_order(&self) -> i8 {
match &self.data {
DirectiveData::Open(_) => -2,
DirectiveData::Balance(_) => -1,
DirectiveData::Document(_) => 1,
DirectiveData::Close(_) => 2,
_ => 0,
}
}
#[must_use]
pub fn sort_key(&self) -> (&str, i8, u32) {
(
&self.date,
self.type_sort_order(),
self.lineno.unwrap_or(u32::MAX),
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum DirectiveData {
#[serde(rename = "transaction")]
Transaction(TransactionData),
#[serde(rename = "balance")]
Balance(BalanceData),
#[serde(rename = "open")]
Open(OpenData),
#[serde(rename = "close")]
Close(CloseData),
#[serde(rename = "commodity")]
Commodity(CommodityData),
#[serde(rename = "pad")]
Pad(PadData),
#[serde(rename = "event")]
Event(EventData),
#[serde(rename = "note")]
Note(NoteData),
#[serde(rename = "document")]
Document(DocumentData),
#[serde(rename = "price")]
Price(PriceData),
#[serde(rename = "query")]
Query(QueryData),
#[serde(rename = "custom")]
Custom(CustomData),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionData {
pub flag: String,
pub payee: Option<String>,
pub narration: String,
pub tags: Vec<String>,
pub links: Vec<String>,
pub metadata: Vec<(String, MetaValueData)>,
pub postings: Vec<PostingData>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceSpan {
pub start: u64,
pub end: u64,
pub file_id: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostingData {
pub account: String,
pub units: Option<AmountData>,
pub cost: Option<CostData>,
pub price: Option<PriceAnnotationData>,
pub flag: Option<String>,
pub metadata: Vec<(String, MetaValueData)>,
#[serde(default)]
pub span: Option<SourceSpan>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AmountData {
pub number: String,
pub currency: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CostNumberData {
PerUnit {
value: String,
},
Total {
value: String,
},
PerUnitFromTotal {
per_unit: String,
total: String,
},
}
impl CostNumberData {
#[must_use]
pub fn per_unit(&self) -> Option<&str> {
match self {
Self::PerUnit { value }
| Self::PerUnitFromTotal {
per_unit: value, ..
} => Some(value),
Self::Total { .. } => None,
}
}
#[must_use]
pub fn total(&self) -> Option<&str> {
match self {
Self::Total { value } | Self::PerUnitFromTotal { total: value, .. } => Some(value),
Self::PerUnit { .. } => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostData {
pub number: Option<CostNumberData>,
pub currency: Option<String>,
pub date: Option<String>,
pub label: Option<String>,
pub merge: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceAnnotationData {
pub is_total: bool,
pub amount: Option<AmountData>,
pub number: Option<String>,
pub currency: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub enum PriceAnnotationView<'a> {
Unit(&'a AmountData),
Total(&'a AmountData),
UnitIncomplete {
number: Option<&'a str>,
currency: Option<&'a str>,
},
TotalIncomplete {
number: Option<&'a str>,
currency: Option<&'a str>,
},
}
impl PriceAnnotationData {
#[must_use]
pub fn view(&self) -> PriceAnnotationView<'_> {
match (self.is_total, &self.amount) {
(false, Some(a)) => PriceAnnotationView::Unit(a),
(true, Some(a)) => PriceAnnotationView::Total(a),
(false, None) => PriceAnnotationView::UnitIncomplete {
number: self.number.as_deref(),
currency: self.currency.as_deref(),
},
(true, None) => PriceAnnotationView::TotalIncomplete {
number: self.number.as_deref(),
currency: self.currency.as_deref(),
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum MetaValueData {
#[serde(rename = "string")]
String(String),
#[serde(rename = "number")]
Number(String),
#[serde(rename = "date")]
Date(String),
#[serde(rename = "account")]
Account(String),
#[serde(rename = "currency")]
Currency(String),
#[serde(rename = "tag")]
Tag(String),
#[serde(rename = "link")]
Link(String),
#[serde(rename = "amount")]
Amount(AmountData),
#[serde(rename = "bool")]
Bool(bool),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BalanceData {
pub account: String,
pub amount: AmountData,
pub tolerance: Option<String>,
#[serde(default)]
pub metadata: Vec<(String, MetaValueData)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenData {
pub account: String,
pub currencies: Vec<String>,
pub booking: Option<String>,
#[serde(default)]
pub metadata: Vec<(String, MetaValueData)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloseData {
pub account: String,
#[serde(default)]
pub metadata: Vec<(String, MetaValueData)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommodityData {
pub currency: String,
#[serde(default)]
pub metadata: Vec<(String, MetaValueData)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PadData {
pub account: String,
pub source_account: String,
#[serde(default)]
pub metadata: Vec<(String, MetaValueData)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventData {
pub event_type: String,
pub value: String,
#[serde(default)]
pub metadata: Vec<(String, MetaValueData)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoteData {
pub account: String,
pub comment: String,
#[serde(default)]
pub metadata: Vec<(String, MetaValueData)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentData {
pub account: String,
pub path: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub links: Vec<String>,
#[serde(default)]
pub metadata: Vec<(String, MetaValueData)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceData {
pub currency: String,
pub amount: AmountData,
#[serde(default)]
pub metadata: Vec<(String, MetaValueData)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryData {
pub name: String,
pub query: String,
#[serde(default)]
pub metadata: Vec<(String, MetaValueData)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomData {
pub custom_type: String,
pub values: Vec<MetaValueData>,
#[serde(default)]
pub metadata: Vec<(String, MetaValueData)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImporterInput {
pub path: String,
pub content: Vec<u8>,
pub account: String,
pub currency: Option<String>,
pub options: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentifyInput {
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentifyOutput {
pub matches: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataOutput {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImporterOutput {
pub directives: Vec<DirectiveWrapper>,
pub warnings: Vec<String>,
pub errors: Vec<PluginError>,
}
impl ImporterOutput {
#[must_use]
pub const fn new(directives: Vec<DirectiveWrapper>) -> Self {
Self {
directives,
warnings: Vec::new(),
errors: Vec::new(),
}
}
#[must_use]
pub const fn empty() -> Self {
Self {
directives: Vec::new(),
warnings: Vec::new(),
errors: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnrichedImporterOutput {
pub entries: Vec<(DirectiveWrapper, EnrichmentWrapper)>,
pub warnings: Vec<String>,
pub errors: Vec<PluginError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnrichmentWrapper {
pub directive_index: usize,
pub confidence: f64,
pub method: String,
pub alternatives: Vec<AlternativeWrapper>,
pub fingerprint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlternativeWrapper {
pub account: String,
pub confidence: f64,
pub method: String,
}
pub fn sort_directives(directives: &mut [DirectiveWrapper]) {
directives.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn op_coverage_accepts_complete_cover_and_rejects_violations() {
use PluginOp::{Delete, Keep};
assert!(validate_op_coverage(3, &[Keep(0), Delete(1), Keep(2)]).is_ok());
assert!(validate_op_coverage(0, &[]).is_ok());
assert!(
validate_op_coverage(2, &[Keep(0), Keep(2)])
.unwrap_err()
.contains("out-of-bounds")
);
assert!(
validate_op_coverage(2, &[Keep(0), Delete(0)])
.unwrap_err()
.contains("more than once")
);
assert!(
validate_op_coverage(2, &[Keep(0)])
.unwrap_err()
.contains("omitted")
);
}
#[test]
fn test_plugin_error_builder() {
let error = PluginError::error("test error").at("file.beancount", 10);
assert_eq!(error.message, "test error");
assert_eq!(error.source_file, Some("file.beancount".to_string()));
assert_eq!(error.line_number, Some(10));
assert_eq!(error.severity, PluginErrorSeverity::Error);
}
#[test]
fn test_plugin_warning() {
let warning = PluginError::warning("test warning");
assert_eq!(warning.severity, PluginErrorSeverity::Warning);
}
#[test]
fn test_directive_sort_order() {
let open = DirectiveWrapper {
directive_type: String::new(),
date: "2024-01-01".to_string(),
filename: None,
lineno: Some(1),
data: DirectiveData::Open(OpenData {
account: "Assets:Bank".to_string(),
currencies: vec![],
booking: None,
metadata: vec![],
}),
};
assert_eq!(open.type_sort_order(), -2);
let close = DirectiveWrapper {
directive_type: String::new(),
date: "2024-01-01".to_string(),
filename: None,
lineno: Some(2),
data: DirectiveData::Close(CloseData {
account: "Assets:Bank".to_string(),
metadata: vec![],
}),
};
assert_eq!(close.type_sort_order(), 2);
}
#[test]
fn test_serde_roundtrip() {
let input = PluginInput {
directives: vec![DirectiveWrapper {
directive_type: String::new(),
date: "2024-01-15".to_string(),
filename: Some("test.beancount".to_string()),
lineno: Some(42),
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: Some("Coffee Shop".to_string()),
narration: "Morning coffee".to_string(),
tags: vec!["food".to_string()],
links: vec![],
metadata: vec![],
postings: vec![PostingData {
account: "Expenses:Food".to_string(),
units: Some(AmountData {
number: "5.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
span: None,
}],
}),
}],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: Some("Test Ledger".to_string()),
},
config: Some("threshold=100".to_string()),
};
let json = serde_json::to_string(&input).unwrap();
let decoded: PluginInput = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.directives.len(), 1);
assert_eq!(decoded.config, Some("threshold=100".to_string()));
let msgpack = rmp_serde::to_vec(&input).unwrap();
let decoded: PluginInput = rmp_serde::from_slice(&msgpack).unwrap();
assert_eq!(decoded.directives.len(), 1);
}
fn amount(number: &str, currency: &str) -> AmountData {
AmountData {
number: number.to_string(),
currency: currency.to_string(),
}
}
#[test]
fn view_unit_complete() {
let pad = PriceAnnotationData {
is_total: false,
amount: Some(amount("1.40", "EUR")),
number: None,
currency: None,
};
match pad.view() {
PriceAnnotationView::Unit(a) => {
assert_eq!(a.number, "1.40");
assert_eq!(a.currency, "EUR");
}
other => panic!("expected Unit, got {other:?}"),
}
}
#[test]
fn view_total_complete() {
let pad = PriceAnnotationData {
is_total: true,
amount: Some(amount("1500", "USD")),
number: None,
currency: None,
};
match pad.view() {
PriceAnnotationView::Total(a) => {
assert_eq!(a.number, "1500");
assert_eq!(a.currency, "USD");
}
other => panic!("expected Total, got {other:?}"),
}
}
#[test]
fn view_unit_incomplete_number_only() {
let pad = PriceAnnotationData {
is_total: false,
amount: None,
number: Some("1.40".to_string()),
currency: None,
};
match pad.view() {
PriceAnnotationView::UnitIncomplete { number, currency } => {
assert_eq!(number, Some("1.40"));
assert_eq!(currency, None);
}
other => panic!("expected UnitIncomplete, got {other:?}"),
}
}
#[test]
fn view_unit_incomplete_currency_only() {
let pad = PriceAnnotationData {
is_total: false,
amount: None,
number: None,
currency: Some("EUR".to_string()),
};
match pad.view() {
PriceAnnotationView::UnitIncomplete { number, currency } => {
assert_eq!(number, None);
assert_eq!(currency, Some("EUR"));
}
other => panic!("expected UnitIncomplete, got {other:?}"),
}
}
#[test]
fn view_unit_incomplete_neither() {
let pad = PriceAnnotationData {
is_total: false,
amount: None,
number: None,
currency: None,
};
match pad.view() {
PriceAnnotationView::UnitIncomplete { number, currency } => {
assert_eq!(number, None);
assert_eq!(currency, None);
}
other => panic!("expected UnitIncomplete, got {other:?}"),
}
}
#[test]
fn view_total_incomplete_number_only() {
let pad = PriceAnnotationData {
is_total: true,
amount: None,
number: Some("1500".to_string()),
currency: None,
};
match pad.view() {
PriceAnnotationView::TotalIncomplete { number, currency } => {
assert_eq!(number, Some("1500"));
assert_eq!(currency, None);
}
other => panic!("expected TotalIncomplete, got {other:?}"),
}
}
#[test]
fn view_total_incomplete_currency_only() {
let pad = PriceAnnotationData {
is_total: true,
amount: None,
number: None,
currency: Some("USD".to_string()),
};
match pad.view() {
PriceAnnotationView::TotalIncomplete { number, currency } => {
assert_eq!(number, None);
assert_eq!(currency, Some("USD"));
}
other => panic!("expected TotalIncomplete, got {other:?}"),
}
}
#[test]
fn view_total_incomplete_neither() {
let pad = PriceAnnotationData {
is_total: true,
amount: None,
number: None,
currency: None,
};
match pad.view() {
PriceAnnotationView::TotalIncomplete { number, currency } => {
assert_eq!(number, None);
assert_eq!(currency, None);
}
other => panic!("expected TotalIncomplete, got {other:?}"),
}
}
#[test]
fn view_amount_present_takes_priority_over_number_currency_fields() {
let pad = PriceAnnotationData {
is_total: false,
amount: Some(amount("1.40", "EUR")),
number: Some("99".to_string()), currency: Some("XYZ".to_string()), };
match pad.view() {
PriceAnnotationView::Unit(a) => {
assert_eq!(a.number, "1.40");
assert_eq!(a.currency, "EUR");
}
other => panic!("expected Unit, got {other:?}"),
}
}
#[test]
fn importer_input_msgpack_roundtrip() {
let mut options = std::collections::HashMap::new();
options.insert("date_column".to_string(), "Date".to_string());
options.insert("delimiter".to_string(), ",".to_string());
let original = ImporterInput {
path: "/path/to/foo.csv".to_string(),
content: vec![0xDE, 0xAD, 0xBE, 0xEF],
account: "Assets:Bank".to_string(),
currency: Some("USD".to_string()),
options,
};
let bytes = rmp_serde::to_vec(&original).unwrap();
let decoded: ImporterInput = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(decoded.path, original.path);
assert_eq!(decoded.content, original.content);
assert_eq!(decoded.account, original.account);
assert_eq!(decoded.currency, original.currency);
assert_eq!(decoded.options, original.options);
}
#[test]
fn importer_output_msgpack_roundtrip_empty() {
let original = ImporterOutput::empty();
let bytes = rmp_serde::to_vec(&original).unwrap();
let decoded: ImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
assert!(decoded.directives.is_empty());
assert!(decoded.warnings.is_empty());
}
#[test]
fn importer_output_msgpack_roundtrip_with_warning() {
let mut out = ImporterOutput::new(vec![]);
out.warnings.push("Skipped row 3: bad date".to_string());
let bytes = rmp_serde::to_vec(&out).unwrap();
let decoded: ImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(decoded.warnings.len(), 1);
assert!(decoded.warnings[0].contains("bad date"));
}
#[test]
fn enrichment_wrapper_msgpack_roundtrip() {
let original = EnrichmentWrapper {
directive_index: 7,
confidence: 0.85,
method: "rule".to_string(),
alternatives: vec![AlternativeWrapper {
account: "Expenses:Groceries".to_string(),
confidence: 0.75,
method: "merchant-dict".to_string(),
}],
fingerprint: Some("abc123def456".to_string()),
};
let bytes = rmp_serde::to_vec(&original).unwrap();
let decoded: EnrichmentWrapper = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(decoded.directive_index, original.directive_index);
assert!((decoded.confidence - original.confidence).abs() < f64::EPSILON);
assert_eq!(decoded.method, original.method);
assert_eq!(decoded.alternatives.len(), 1);
assert_eq!(decoded.alternatives[0].account, "Expenses:Groceries");
assert!(
(decoded.alternatives[0].confidence - 0.75).abs() < f64::EPSILON,
"alternative confidence must round-trip exactly"
);
assert_eq!(decoded.alternatives[0].method, "merchant-dict");
assert_eq!(decoded.fingerprint, original.fingerprint);
}
#[test]
fn enriched_importer_output_msgpack_roundtrip() {
let dir = DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2024-01-15".to_string(),
filename: Some("/tmp/foo.csv".to_string()),
lineno: Some(7),
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: Some("Whole Foods".to_string()),
narration: "Groceries".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![],
}),
};
let enr = EnrichmentWrapper {
directive_index: 0,
confidence: 0.92,
method: "rule".to_string(),
alternatives: vec![AlternativeWrapper {
account: "Expenses:Other".to_string(),
confidence: 0.10,
method: "default".to_string(),
}],
fingerprint: Some("dead-beef".to_string()),
};
let original = EnrichedImporterOutput {
entries: vec![(dir, enr)],
warnings: vec!["row 3 skipped".to_string()],
errors: vec![PluginError::error("row 4 unparsable").at("/tmp/foo.csv", 4)],
};
let bytes = rmp_serde::to_vec(&original).unwrap();
let decoded: EnrichedImporterOutput = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(decoded.entries.len(), 1);
let (dir, enr) = &decoded.entries[0];
assert_eq!(dir.date, "2024-01-15");
match &dir.data {
DirectiveData::Transaction(t) => {
assert_eq!(t.payee.as_deref(), Some("Whole Foods"));
assert_eq!(t.narration, "Groceries");
}
other => panic!("expected Transaction, got {other:?}"),
}
assert_eq!(enr.directive_index, 0);
assert!((enr.confidence - 0.92).abs() < f64::EPSILON);
assert_eq!(enr.method, "rule");
assert_eq!(enr.alternatives.len(), 1);
assert_eq!(enr.alternatives[0].method, "default");
assert_eq!(enr.fingerprint, Some("dead-beef".to_string()));
assert_eq!(decoded.warnings, vec!["row 3 skipped".to_string()]);
assert_eq!(decoded.errors.len(), 1);
assert_eq!(decoded.errors[0].message, "row 4 unparsable");
assert_eq!(
decoded.errors[0].source_file,
Some("/tmp/foo.csv".to_string())
);
assert_eq!(decoded.errors[0].line_number, Some(4));
}
#[test]
fn identify_input_output_msgpack_roundtrip() {
let input = IdentifyInput {
path: "/tmp/statement.mt940".to_string(),
};
let input_bytes = rmp_serde::to_vec(&input).unwrap();
let decoded_input: IdentifyInput = rmp_serde::from_slice(&input_bytes).unwrap();
assert_eq!(decoded_input.path, input.path);
let output = IdentifyOutput { matches: true };
let output_bytes = rmp_serde::to_vec(&output).unwrap();
let decoded_output: IdentifyOutput = rmp_serde::from_slice(&output_bytes).unwrap();
assert!(decoded_output.matches);
}
#[test]
fn metadata_output_msgpack_roundtrip() {
let original = MetadataOutput {
name: "MT940".to_string(),
description: "SWIFT MT940 bank statement importer".to_string(),
};
let bytes = rmp_serde::to_vec(&original).unwrap();
let decoded: MetadataOutput = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(decoded.name, original.name);
assert_eq!(decoded.description, original.description);
}
}