feat: add crabrl-fork to workspace and fix taxonomy loading

- Add crabrl-fork to workspace Cargo.toml
- Update fiscal-xbrl-core to use local crabrl-fork
- Fix SurfaceFormulaOp enum: add Divide variant
- Fix SurfaceSignTransform enum: add Absolute variant
- Implement divide_formula_values function for SurfaceFormulaOp::Divide
- Implement Absolute sign transform handler
- Fix all taxonomy_loader and related tests to pass
This commit is contained in:
2026-03-15 20:32:07 -04:00
parent 4313058d65
commit d73f09c15e
8 changed files with 195 additions and 246 deletions

View File

@@ -1,5 +1,7 @@
use anyhow::{anyhow, Context, Result};
use once_cell::sync::Lazy;
use quick_xml::events::Event;
use quick_xml::Reader;
use regex::Regex;
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
@@ -16,9 +18,6 @@ mod universal_income;
use taxonomy_loader::{ComputationSpec, ComputedDefinition};
#[cfg(feature = "with-crabrl")]
use crabrl as _;
pub const PARSER_ENGINE: &str = "fiscal-xbrl";
pub const PARSER_VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -1062,24 +1061,79 @@ fn validate_xbrl_structure(xml: &str, source_file: Option<&str>) -> XbrlValidati
};
}
if !xml.contains("<xbrl") && !xml.contains("<xbrli:xbrl") {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut has_xbrl_root = false;
let mut depth: i32 = 0;
let mut tag_stack: Vec<String> = Vec::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
// Check for XBRL root element
if depth == 0 && (name == "xbrl" || name.ends_with(":xbrl")) {
has_xbrl_root = true;
}
if matches!(reader.read_event_into(&mut buf), Ok(Event::Start(_))) {
depth += 1;
tag_stack.push(name);
}
}
Ok(Event::End(e)) => {
if depth > 0 {
depth -= 1;
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
if let Some(expected) = tag_stack.pop() {
if expected != name {
return XbrlValidationResult {
status: "warning".to_string(),
message: Some(format!(
"Mismatched tags in {:?}: expected </{}>, found </{}>",
source_file.unwrap_or("unknown"),
expected,
name
)),
};
}
}
}
}
Ok(Event::Eof) => break,
Err(e) => {
return XbrlValidationResult {
status: "warning".to_string(),
message: Some(format!(
"XML parse error in {:?} at byte {}: {}",
source_file.unwrap_or("unknown"),
reader.error_position(),
e
)),
};
}
_ => {}
}
buf.clear();
}
if !has_xbrl_root {
return XbrlValidationResult {
status: "error".to_string(),
message: Some("Invalid XBRL: missing root element".to_string()),
};
}
let open_count = xml.matches('<').count();
let close_count = xml.matches('>').count();
if open_count != close_count {
if depth != 0 {
return XbrlValidationResult {
status: "warning".to_string(),
message: Some(format!(
"Malformed XML detected in {:?} ({} open, {} close tags)",
"Unclosed tags in {:?}: {} unclosed element(s)",
source_file.unwrap_or("unknown"),
open_count,
close_count
depth
)),
};
}

View File

@@ -571,6 +571,7 @@ fn evaluate_formula_for_period(
match formula.op {
SurfaceFormulaOp::Sum => sum_formula_values(&values, formula.treat_null_as_zero),
SurfaceFormulaOp::Subtract => subtract_formula_values(&values, formula.treat_null_as_zero),
SurfaceFormulaOp::Divide => divide_formula_values(&values, formula.treat_null_as_zero),
}
}
@@ -612,6 +613,33 @@ fn subtract_formula_values(values: &[Option<f64>], treat_null_as_zero: bool) ->
Some(left - right)
}
fn divide_formula_values(values: &[Option<f64>], treat_null_as_zero: bool) -> Option<f64> {
if values.len() != 2 {
return None;
}
let left = if treat_null_as_zero {
values[0].unwrap_or(0.0)
} else {
values[0]?
};
let right = if treat_null_as_zero {
values[1].unwrap_or(0.0)
} else {
values[1]?
};
if right == 0.0 {
return None;
}
if !treat_null_as_zero && values.iter().all(|value| value.is_none()) {
return None;
}
Some(left / right)
}
pub fn merge_mapping_assignments(
primary: &mut HashMap<String, MappingAssignment>,
secondary: HashMap<String, MappingAssignment>,
@@ -831,6 +859,7 @@ fn transform_values(
period_id.clone(),
match sign_transform {
Some(SurfaceSignTransform::Invert) => value.map(|amount| -amount),
Some(SurfaceSignTransform::Absolute) => value.map(|amount| amount.abs()),
None => *value,
},
)

View File

@@ -15,6 +15,7 @@ fn default_include_in_output() -> bool {
#[serde(rename_all = "snake_case")]
pub enum SurfaceSignTransform {
Invert,
Absolute,
}
#[derive(Debug, Deserialize, Clone)]
@@ -73,6 +74,7 @@ pub struct SurfaceFormula {
pub enum SurfaceFormulaOp {
Sum,
Subtract,
Divide,
}
#[derive(Debug, Deserialize, Clone)]