static USAGE: &str = r#"
Renders a template using CSV data with the MiniJinja template engine.
https://docs.rs/minijinja/latest/minijinja/
This command processes each row of the CSV file, making the column values available as variables.
Each row is rendered using the template. Column headers become variable names, with non-alphanumeric
characters converted to underscore (_).
Templates use Jinja2 syntax (https://jinja.palletsprojects.com/en/stable/templates/)
and can access an extensive library of built-in filters/functions, with additional ones
from minijinja_contrib https://docs.rs/minijinja-contrib/latest/minijinja_contrib/.
Additional qsv custom filters are also documented at the end of this file.
If the <outdir> argument is specified, it will create a file for each row in <outdir>, with
the filename rendered using --outfilename option.
Otherwise, ALL the rendered rows will be sent to STDOUT or the designated --output.
Example:
data.csv:
"first name","last name",balance,"loyalty points",active,us_state
alice,jones,100.50,1000,true,TX
bob,smith,200.75,2000,false,CA
john,doe,10,1,true,NJ
NOTE: All variables are of type String and will need to be cast with the `|float` or `|int`
filters for math operations and when a MiniJinja filter/function requires it.
qsv's custom filters (substr, format_float, human_count, human_float_count, round_banker &
str_to_bool) do not require casting for convenience.
template.tpl
{% set us_state_lookup_loaded = register_lookup("us_states", "dathere://us-states-example.csv") -%}
Dear {{ first_name|title }} {{ last_name|title }}!
Your account balance is {{ balance|format_float(2) }}
with {{ loyalty_points|human_count }} point{{ loyalty_points|int|pluralize }}!
{# This is a comment and will not be rendered. The closing minus sign in this
block tells MiniJinja to trim whitespaces -#}
{% if us_state_lookup_loaded -%}
{% if us_state not in ["DE", "CA"] -%}
{% set tax_rate = us_state|lookup("us_states", "Sales Tax (2023)")|float -%}
State: {{ us_state|lookup("us_states", "Name") }} {{us_state}} Tax Rate: {{ tax_rate }}%
{% set loyalty_value = loyalty_points|int / 100 -%}
{%- set tax_amount = loyalty_value * (tax_rate / 100) -%}
{%- set loyalty_value = loyalty_value - tax_amount -%}
Value of Points: {{ loyalty_value }}
{% else %}
{% set loyalty_value = 0 -%}
{% endif %}
Final Balance: {{ (balance|int - loyalty_value)|format_float(2) }}
{% endif %}
Status: {% if active|to_bool %}Active{% else %}Inactive{% endif %}
$ qsv template --template-file template.tpl data.csv
For more examples, see https://github.com/dathere/qsv/blob/master/tests/test_template.rs.
For a relatively complex MiniJinja template, see https://github.com/dathere/qsv/blob/master/scripts/template.tpl
Usage:
qsv template [options] [--template <str> | --template-file <file>] [<input>] [<outdir> | --output <file>]
qsv template --help
template arguments:
<input> The CSV file to read. If not given, input is read from STDIN.
<outdir> The directory where the output files will be written.
If it does not exist, it will be created.
If not set, output will be sent to stdout or the specified --output.
When writing to <outdir>, files are organized into subdirectories
of --outsubdir-size (default: 1000) files each to avoid filesystem
navigation & performance issues.
For example, with 3500 records:
* <outdir>/0000/0001.txt through <outdir>/0000/1000.txt
* <outdir>/0001/1001.txt through <outdir>/0001/2000.txt
* <outdir>/0002/2001.txt through <outdir>/0002/3000.txt
* <outdir>/0003/3001.txt through <outdir>/0003/4000.txt
template options:
--template <str> MiniJinja template string to use (alternative to --template-file)
-t, --template-file <file> MiniJinja template file to use
-j, --globals-json <file> A JSON file containing global variables to make available in templates.
The JSON properties can be accessed in templates using the "qsv_g"
namespace (e.g. {{qsv_g.school_name}}, {{qsv_g.year}}).
This allows sharing common values across all template renders.
--outfilename <str> MiniJinja template string to use to create the filename of the output
files to write to <outdir>. If set to just QSV_ROWNO, the filestem
is set to the current rowno of the record, padded with leading
zeroes, with the ".txt" extension (e.g. 001.txt, 002.txt, etc.)
Note that all the fields, including QSV_ROWNO, are available
when defining the filename template.
[default: QSV_ROWNO]
--outsubdir-size <num> The number of files per subdirectory in <outdir>.
[default: 1000]
--customfilter-error <msg> The value to return when a custom filter returns an error.
Use "<empty string>" to return an empty string.
[default: <FILTER_ERROR>]
-j, --jobs <arg> The number of jobs to run in parallel.
When not set, the number of jobs is set to the number of CPUs detected.
-b, --batch <size> The number of rows per batch to load into memory, before running in parallel.
Set to 0 to load all rows in one batch.
[default: 50000]
--timeout <seconds> Timeout for downloading lookups on URLs. [default: 30]
--cache-dir <dir> The directory to use for caching downloaded lookup resources.
If the directory does not exist, qsv will attempt to create it.
If the QSV_CACHE_DIR envvar is set, it will be used instead.
[default: ~/.qsv-cache]
--ckan-api <url> The URL of the CKAN API to use for downloading lookup resources
with the "ckan://" scheme.
If the QSV_CKAN_API envvar is set, it will be used instead.
[default: https://data.dathere.com/api/3/action]
--ckan-token <token> The CKAN API token to use. Only required if downloading private resources.
If the QSV_CKAN_TOKEN envvar is set, it will be used instead.
Common options:
-h, --help Display this message
-o, --output <file> Write output to <file> instead of stdout
-n, --no-headers When set, the first row will not be interpreted
as headers. Templates must use numeric 1-based indices
with the "_c" prefix.(e.g. col1: {{_c1}} col2: {{_c2}})
--delimiter <sep> Field separator for reading CSV [default: ,]
-p, --progressbar Show progress bars. Not valid for stdin.
"#;
use std::{
fmt::Write as _,
fs,
io::{BufWriter, Write},
path::PathBuf,
sync::{
OnceLock, RwLock,
atomic::{AtomicBool, AtomicU16, Ordering},
},
};
use foldhash::{HashMap, HashMapExt};
#[cfg(any(feature = "feature_capable", feature = "lite"))]
use indicatif::{ProgressBar, ProgressDrawTarget};
use minijinja::{Environment, Value, value::ValueKind};
use minijinja_contrib::pycompat::unknown_method_callback;
use rayon::{
iter::{IndexedParallelIterator, ParallelIterator},
prelude::IntoParallelRefIterator,
};
use serde::Deserialize;
use simd_json::{BorrowedValue, json};
use crate::{
CliError, CliResult,
config::{Config, DEFAULT_WTR_BUFFER_CAPACITY, Delimiter},
lookup,
lookup::LookupTableOptions,
util,
};
const QSV_ROWNO: &str = "QSV_ROWNO";
#[derive(Deserialize)]
struct Args {
arg_input: Option<String>,
arg_outdir: Option<String>,
flag_template: Option<String>,
flag_template_file: Option<PathBuf>,
flag_globals_json: Option<PathBuf>,
flag_output: Option<String>,
flag_outfilename: String,
flag_outsubdir_size: u16,
flag_customfilter_error: String,
flag_jobs: Option<usize>,
flag_batch: usize,
flag_delimiter: Option<Delimiter>,
flag_no_headers: bool,
#[allow(dead_code)]
flag_progressbar: bool,
flag_timeout: u16,
flag_cache_dir: String,
flag_ckan_api: String,
flag_ckan_token: Option<String>,
}
static FILTER_ERROR: OnceLock<String> = OnceLock::new();
static EMPTY_FILTER_ERROR: AtomicBool = AtomicBool::new(false);
impl From<minijinja::Error> for CliError {
fn from(err: minijinja::Error) -> CliError {
CliError::Other(err.to_string())
}
}
type LookupMap = HashMap<String, HashMap<String, HashMap<String, String>>>;
static LOOKUP_MAP: OnceLock<RwLock<LookupMap>> = OnceLock::new();
static QSV_CACHE_DIR: OnceLock<String> = OnceLock::new();
static TIMEOUT_SECS: AtomicU16 = AtomicU16::new(30);
static CKAN_API: OnceLock<String> = OnceLock::new();
static CKAN_TOKEN: OnceLock<Option<String>> = OnceLock::new();
static DELIMITER: OnceLock<Option<Delimiter>> = OnceLock::new();
pub fn run(argv: &[&str]) -> CliResult<()> {
let args: Args = util::get_args(USAGE, argv)?;
let template_content = match (args.flag_template_file, args.flag_template) {
(Some(path), None) => fs::read_to_string(path)?,
(None, Some(template)) => template,
_ => {
return fail_incorrectusage_clierror!(
"Must provide either --template or --template-file"
);
},
};
if FILTER_ERROR
.set(if args.flag_customfilter_error == "<empty string>" {
EMPTY_FILTER_ERROR.store(true, Ordering::Relaxed);
String::new()
} else {
args.flag_customfilter_error
})
.is_err()
{
return fail!("Cannot initialize custom filter error message.");
}
TIMEOUT_SECS.store(
util::timeout_secs(args.flag_timeout)? as u16,
Ordering::Relaxed,
);
let mut globals_flag = false;
let globals_ctx = if let Some(globals_json) = args.flag_globals_json {
globals_flag = true;
match std::fs::read(globals_json) {
Ok(mut bytes) => match simd_json::from_slice(&mut bytes) {
Ok(json) => json,
Err(e) => return fail_clierror!("Failed to parse globals JSON file: {e}"),
},
Err(e) => return fail_clierror!("Failed to read globals JSON file: {e}"),
}
} else {
json!("")
};
let globals_ctx_borrowed: simd_json::BorrowedValue = globals_ctx.into();
let mut env = Environment::new();
minijinja_contrib::add_to_environment(&mut env);
env.set_unknown_method_callback(unknown_method_callback);
env.add_function("register_lookup", register_lookup);
env.add_filter("substr", substr);
env.add_filter("format_float", format_float);
env.add_filter("human_count", human_count);
env.add_filter("human_float_count", human_float_count);
env.add_filter("round_banker", round_banker);
env.add_filter("to_bool", to_bool);
env.add_filter("lookup", lookup_filter);
env.add_template("template", &template_content)?;
let template = env.get_template("template")?;
let rconfig = Config::new(args.arg_input.as_ref())
.delimiter(args.flag_delimiter)
.no_headers_flag(args.flag_no_headers);
let mut rdr = rconfig.reader()?;
let (rowcount, width) = if rconfig.is_stdin() {
(0, 6) } else {
let rowcount = util::count_rows(&rconfig)?;
if rconfig.no_headers && rowcount == 1 {
return Ok(());
}
let width = rowcount.to_string().len();
(rowcount, width)
};
let headers = if args.flag_no_headers {
csv::StringRecord::new()
} else {
let headers = rdr.headers()?.clone();
let mut sanitized_headers: Vec<String> = headers
.iter()
.map(|h| {
h.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect()
})
.collect();
sanitized_headers.push(QSV_ROWNO.to_owned());
csv::StringRecord::from(sanitized_headers)
};
let headers_len = headers.len();
let output_to_dir = args.arg_outdir.is_some();
let mut row_no = 0_u64;
let use_rowno_filename = args.flag_outfilename == QSV_ROWNO;
#[allow(unused_assignments)]
let mut filename_env = Environment::empty();
let filename_template = if output_to_dir && !use_rowno_filename {
filename_env = Environment::new();
minijinja_contrib::add_to_environment(&mut filename_env);
filename_env.set_unknown_method_callback(unknown_method_callback);
filename_env.add_template("filename", &args.flag_outfilename)?;
filename_env.get_template("filename")?
} else {
filename_env.template_from_str("")?
};
let mut bulk_wtr = if output_to_dir {
fs::create_dir_all(args.arg_outdir.as_ref().unwrap())?;
None
} else {
Some(match args.flag_output {
Some(file) => Box::new(BufWriter::with_capacity(
DEFAULT_WTR_BUFFER_CAPACITY,
fs::File::create(file)?,
)) as Box<dyn Write>,
None => Box::new(BufWriter::with_capacity(
DEFAULT_WTR_BUFFER_CAPACITY,
std::io::stdout(),
)) as Box<dyn Write>,
})
};
let num_jobs = util::njobs(args.flag_jobs);
let batchsize = util::optimal_batch_size(&rconfig, args.flag_batch, num_jobs);
#[cfg(any(feature = "feature_capable", feature = "lite"))]
let show_progress =
(args.flag_progressbar || util::get_envvar_flag("QSV_PROGRESSBAR")) && !rconfig.is_stdin();
#[cfg(any(feature = "feature_capable", feature = "lite"))]
let progress = ProgressBar::with_draw_target(None, ProgressDrawTarget::stderr_with_hz(5));
#[cfg(any(feature = "feature_capable", feature = "lite"))]
if show_progress {
util::prep_progress(&progress, rowcount);
} else {
progress.set_draw_target(ProgressDrawTarget::hidden());
}
DELIMITER.set(args.flag_delimiter).unwrap();
let qsv_cache_dir = lookup::set_qsv_cache_dir(&args.flag_cache_dir)?;
QSV_CACHE_DIR.set(qsv_cache_dir)?;
CKAN_API.set(if let Ok(api) = std::env::var("QSV_CKAN_API") {
api
} else {
args.flag_ckan_api.clone()
})?;
CKAN_TOKEN
.set(if let Ok(token) = std::env::var("QSV_CKAN_TOKEN") {
Some(token)
} else {
args.flag_ckan_token.clone()
})
.unwrap();
if template_content.contains("register_lookup(") {
let re = regex::Regex::new(r"register_lookup\([^)]+\)")?;
let temp_template = re.find_iter(&template_content)
.fold(String::new(), |mut acc, cap| {
write!(
acc,
r#"{{% if not {cap_str} %}}LOOKUP REGISTRATION ERROR: "{cap_str}"\n{{% endif %}}"#,
cap_str = cap.as_str(),
).unwrap();
acc
});
let temp_env = env.clone();
match temp_env.render_str(&temp_template, minijinja::context! {}) {
Ok(s) => {
if !s.is_empty() {
return fail_incorrectusage_clierror!("{s}");
}
},
Err(e) => return fail_incorrectusage_clierror!("{e}"),
}
}
#[allow(unused_assignments)]
let mut batch_record = csv::StringRecord::new();
let mut batch: Vec<csv::StringRecord> = Vec::with_capacity(batchsize);
let mut batch_results: Vec<(Option<String>, String)> = Vec::with_capacity(batchsize);
let no_headers = args.flag_no_headers;
'batch_loop: loop {
for _ in 0..batchsize {
match rdr.read_record(&mut batch_record) {
Ok(has_data) => {
if has_data {
row_no += 1;
batch_record.push_field(itoa::Buffer::new().format(row_no));
batch.push(std::mem::take(&mut batch_record));
} else {
break;
}
},
Err(e) => {
return fail_clierror!("Error reading file: {e}");
},
}
}
if batch.is_empty() {
break 'batch_loop;
}
batch
.par_iter()
.with_min_len(1024)
.map(|record| {
let curr_record = record;
let mut context = simd_json::borrowed::Object::default();
if globals_flag {
context.insert(
std::borrow::Cow::Borrowed("qsv_g"),
globals_ctx_borrowed.clone(),
);
}
context.reserve(headers_len);
let mut row_number = 0_u64;
if no_headers {
let headers_len = curr_record.len();
for (i, field) in curr_record.iter().enumerate() {
if i == headers_len - 1 {
row_number = atoi_simd::parse::<u64>(field.as_bytes()).unwrap();
context.insert(
std::borrow::Cow::Borrowed(QSV_ROWNO),
BorrowedValue::String(std::borrow::Cow::Borrowed(field)),
);
} else {
context.insert(
format!("_c{}", i + 1).into(),
BorrowedValue::String(std::borrow::Cow::Borrowed(field)),
);
}
}
} else {
for (header, field) in headers.iter().zip(curr_record.iter()) {
context.insert(
std::borrow::Cow::Borrowed(header),
BorrowedValue::String(std::borrow::Cow::Borrowed(field)),
);
if header == QSV_ROWNO {
row_number = atoi_simd::parse::<u64>(field.as_bytes()).unwrap();
}
}
}
let rendered = match template.render(&context) {
Ok(s) => s,
Err(e) => format!("RENDERING ERROR ({row_number}): {e}\n"),
};
if output_to_dir {
let outfilename = if use_rowno_filename {
format!("{row_number:0width$}.txt")
} else {
filename_template.render(&context).unwrap_or_else(|_| {
format!("FILENAME_RENDERING_ERROR-{row_number:0width$}.txt")
})
};
(Some(outfilename), rendered)
} else {
(None, rendered)
}
})
.collect_into_vec(&mut batch_results);
let mut outpath = std::path::PathBuf::new();
let mut current_subdir = None;
let outsubdir_numfiles = args.flag_outsubdir_size as usize;
for (idx, result_record) in batch_results.iter().enumerate() {
if output_to_dir {
outpath.push(args.arg_outdir.as_ref().unwrap());
let subdir_num = idx / outsubdir_numfiles;
if current_subdir == Some(subdir_num) {
outpath.push(format!("{subdir_num:0width$}"));
} else {
let subdir_name = format!("{subdir_num:0width$}");
outpath.push(&subdir_name);
if !outpath.exists() {
fs::create_dir(&outpath)?;
}
current_subdir = Some(subdir_num);
}
outpath.push(result_record.0.as_deref().unwrap());
let mut row_writer =
BufWriter::with_capacity(result_record.1.len(), fs::File::create(&outpath)?);
row_writer.write_all(result_record.1.as_bytes())?;
row_writer.flush()?;
outpath.clear();
} else if let Some(ref mut w) = bulk_wtr {
w.write_all(result_record.1.as_bytes())?;
}
}
#[cfg(any(feature = "feature_capable", feature = "lite"))]
if show_progress {
progress.inc(batch.len() as u64);
}
batch.clear();
}
#[cfg(any(feature = "feature_capable", feature = "lite"))]
if show_progress {
util::finish_progress(&progress);
}
if let Some(mut w) = bulk_wtr {
w.flush()?;
}
Ok(())
}
fn substr(value: &str, start: u32, end: Option<u32>) -> String {
let end = end.unwrap_or(value.len() as _);
if let Some(s) = value.get(start as usize..end as usize) {
s.into()
} else {
FILTER_ERROR.get().unwrap().clone()
}
}
fn format_float(value: &Value, precision: u32) -> String {
let precision = precision.min(16) as usize;
if value.kind() == ValueKind::String {
if let Some(s) = value.as_str() {
s.parse::<f64>().map_or_else(
|_| FILTER_ERROR.get().unwrap().clone(),
|num| format!("{num:.precision$}"),
)
} else if EMPTY_FILTER_ERROR.load(Ordering::Relaxed) {
FILTER_ERROR.get().unwrap().clone()
} else {
format!(
r#"{}: "{value}" is not a float."#,
FILTER_ERROR.get().unwrap()
)
}
} else {
FILTER_ERROR.get().unwrap().clone()
}
}
fn human_count(value: &Value) -> String {
if value.kind() == ValueKind::String {
let s = value.as_str().unwrap().as_bytes();
atoi_simd::parse::<u64>(s).map_or_else(
|_| {
if EMPTY_FILTER_ERROR.load(Ordering::Relaxed) {
FILTER_ERROR.get().unwrap().clone()
} else {
format!(
r#"{}: "{value}" is not an integer."#,
FILTER_ERROR.get().unwrap()
)
}
},
|num| indicatif::HumanCount(num).to_string(),
)
} else {
FILTER_ERROR.get().unwrap().clone()
}
}
fn human_float_count(value: &Value) -> String {
if value.kind() == ValueKind::String {
let s = value.as_str().unwrap();
s.parse::<f64>().map_or_else(
|_| {
if EMPTY_FILTER_ERROR.load(Ordering::Relaxed) {
FILTER_ERROR.get().unwrap().clone()
} else {
format!(
r#"{}: "{value}" is not a float."#,
FILTER_ERROR.get().unwrap()
)
}
},
|num| indicatif::HumanFloatCount(num).to_string(),
)
} else {
FILTER_ERROR.get().unwrap().clone()
}
}
fn round_banker(value: &Value, places: u32) -> String {
if value.kind() == ValueKind::String {
let s = value.as_str().unwrap();
s.parse::<f64>().map_or_else(
|_| {
if EMPTY_FILTER_ERROR.load(Ordering::Relaxed) {
FILTER_ERROR.get().unwrap().clone()
} else {
format!(
r#"{}: "{value}" is not a float."#,
FILTER_ERROR.get().unwrap()
)
}
},
|num| util::round_num(num, places),
)
} else {
FILTER_ERROR.get().unwrap().clone()
}
}
fn to_bool(value: &Value) -> bool {
if value.kind() == ValueKind::String {
let s = value.as_str().unwrap();
let truthy = matches!(
s.to_ascii_lowercase().as_str(),
"true" | "1" | "yes" | "t" | "y"
);
if truthy {
true
} else {
let int_num: i64;
let float_num: f64;
if let Ok(num) = s.parse::<i64>() {
int_num = num;
int_num != 0
} else if let Ok(num) = s.parse::<f64>() {
float_num = num;
float_num.abs() > f64::EPSILON
} else {
false
}
}
} else {
value.is_true()
}
}
fn register_lookup(
lookup_name: &str,
lookup_table_uri: &str,
cache_age_secs: Option<i64>,
) -> Result<bool, minijinja::Error> {
if lookup_name.is_empty() {
return Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"lookup name cannot be empty",
));
}
let cache_age_secs = cache_age_secs.unwrap_or(3600);
if let Some(lock) = LOOKUP_MAP.get()
&& let Ok(map) = lock.read()
&& map.contains_key(lookup_name)
&& cache_age_secs > 0
{
return Ok(true);
}
if lookup_table_uri.is_empty() {
return Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"lookup table URI cannot be empty",
));
}
let lookup_opts = LookupTableOptions {
name: lookup_name.to_string(),
uri: lookup_table_uri.to_string(),
cache_dir: QSV_CACHE_DIR
.get()
.ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"cache directory not initialized",
)
})?
.to_string(),
cache_age_secs,
delimiter: DELIMITER.get().copied().flatten(),
ckan_api_url: CKAN_API.get().cloned(),
ckan_token: CKAN_TOKEN.get().and_then(std::clone::Clone::clone),
timeout_secs: TIMEOUT_SECS.load(Ordering::Relaxed),
};
let lookup_table = lookup::load_lookup_table(&lookup_opts).map_err(|e| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(r#"failed to load lookup table "{}": {e}"#, lookup_opts.name),
)
})?;
if lookup_table.rowcount == 0 {
return Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"lookup table is empty",
));
}
let lookup_config = Config::new(Some(lookup_table.filepath.clone()).as_ref())
.delimiter(lookup_opts.delimiter)
.comment(Some(b'#'))
.no_headers(false);
let mut rdr = lookup_config.reader().map_err(|e| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
r#"failed to read CSV file "{}": {e}"#,
lookup_table.filepath
),
)
})?;
let mut lookup_data: HashMap<String, HashMap<String, String>> =
HashMap::with_capacity(lookup_table.rowcount);
let row_len = lookup_table.headers.len();
for record in rdr.records().flatten() {
let mut row_data: HashMap<String, String> =
HashMap::with_capacity_and_hasher(row_len, foldhash::fast::RandomState::default());
for (header, value) in lookup_table.headers.iter().zip(record.iter()) {
row_data.insert(header.to_owned(), value.to_owned());
}
if let Some(key_value) = record.get(0) {
let key_trim = key_value.trim();
let key = if let Ok(num) = key_trim.parse::<i64>() {
itoa::Buffer::new().format(num).to_owned()
} else if let Ok(num) = key_trim.parse::<f64>() {
zmij::Buffer::new().format(num).to_owned()
} else {
key_trim.to_owned()
};
lookup_data.insert(key, row_data);
}
}
if LOOKUP_MAP.get().is_none() && LOOKUP_MAP.set(RwLock::new(HashMap::new())).is_err() {
return Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"failed to initialize lookup map",
));
}
match LOOKUP_MAP.get().unwrap().write() {
Ok(mut map) => {
map.insert(lookup_name.to_string(), lookup_data);
Ok(true)
},
Err(_) => Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"failed to acquire write lock on lookup map",
)),
}
}
fn lookup_filter(
value: &Value,
lookup_name: &str,
field: &str,
case_sensitive: Option<bool>,
) -> Result<String, minijinja::Error> {
if lookup_name.is_empty() {
return Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"lookup name not provided",
));
}
if field.is_empty() {
return Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"lookup field not provided",
));
}
let case_sensitive = case_sensitive.unwrap_or(true);
let mut itoa_buf = itoa::Buffer::new();
let mut zmij_buf = zmij::Buffer::new();
let value = match value.kind() {
ValueKind::String => value.as_str().unwrap(),
ValueKind::Number => {
if value.is_integer() {
itoa_buf.format(value.as_i64().unwrap())
} else {
let float_num: f64;
match value.clone().try_into() {
Ok(num) => {
float_num = num;
zmij_buf.format(float_num)
},
_ => {
unreachable!("Kind::Number should be integer or float")
},
}
}
},
_ => value.as_str().unwrap_or_default(),
};
let value_compare = if case_sensitive {
value
} else {
&value.to_lowercase()
};
let filter_error = FILTER_ERROR.get().unwrap();
let mut lowercase_buffer = String::with_capacity(32);
Ok(LOOKUP_MAP
.get()
.and_then(|lock| lock.read().ok())
.and_then(|map| {
let table = map.get(lookup_name)?;
if case_sensitive {
table
.get(value)
.and_then(|row| row.get(field).map(String::from))
} else {
table
.iter()
.find(|(k, _)| {
util::to_lowercase_into(k, &mut lowercase_buffer);
lowercase_buffer == value_compare
})
.and_then(|(_, row)| row.get(field).map(String::from))
}
})
.unwrap_or_else(|| {
if filter_error.is_empty() {
String::new()
} else {
format!(
r#"{filter_error} - lookup: "{lookup_name}-{field}" not found for: "{value}""#
)
}
}))
}