use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use regex::Regex;
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::plot::ArrayElement;
#[derive(Debug, Clone)]
enum Placeholder {
Plain,
Upper,
Lower,
Title,
DateTime(String),
Number(String),
}
#[derive(Debug, Clone)]
struct ParsedPlaceholder {
placeholder: Placeholder,
match_text: String,
}
fn placeholder_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\{([^}]*)\}").expect("Invalid placeholder regex"))
}
fn parse_placeholders(template: &str) -> Vec<ParsedPlaceholder> {
placeholder_regex()
.find_iter(template)
.map(|cap| {
let inner = &template[cap.start() + 1..cap.end() - 1];
let placeholder = match inner {
"" => Placeholder::Plain,
":UPPER" => Placeholder::Upper,
":lower" => Placeholder::Lower,
":Title" => Placeholder::Title,
s if s.starts_with(":time ") => {
Placeholder::DateTime(s.strip_prefix(":time ").unwrap().to_string())
}
s if s.starts_with(":num ") => {
Placeholder::Number(s.strip_prefix(":num ").unwrap().to_string())
}
_ => Placeholder::Plain, };
ParsedPlaceholder {
placeholder,
match_text: cap.as_str().to_string(),
}
})
.collect()
}
fn apply_transformation(
value: &str,
placeholder: &Placeholder,
precision: Option<usize>,
) -> String {
match placeholder {
Placeholder::Plain => match precision {
Some(prec) => match value.parse::<f64>() {
Ok(n) => format!("{n:.prec$}"),
Err(_) => value.to_string(),
},
None => value.to_string(),
},
Placeholder::Upper => value.to_uppercase(),
Placeholder::Lower => value.to_lowercase(),
Placeholder::Title => to_title_case(value),
Placeholder::DateTime(fmt) => format_datetime(value, fmt),
Placeholder::Number(fmt) => format_number_with_spec(value, fmt),
}
}
fn to_title_case(s: &str) -> String {
s.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first
.to_uppercase()
.chain(chars.flat_map(|c| c.to_lowercase()))
.collect(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn format_datetime(value: &str, fmt: &str) -> String {
if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") {
return dt.format(fmt).to_string();
}
if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f") {
return dt.format(fmt).to_string();
}
if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S") {
return dt.format(fmt).to_string();
}
if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S%.f") {
return dt.format(fmt).to_string();
}
if let Ok(d) = NaiveDate::parse_from_str(value, "%Y-%m-%d") {
return d.format(fmt).to_string();
}
if let Ok(d) = NaiveTime::parse_from_str(value, "%H:%M:%S") {
return d.format(fmt).to_string();
}
if let Ok(d) = NaiveTime::parse_from_str(value, "%H:%M:%S%.f") {
return d.format(fmt).to_string();
}
value.to_string()
}
fn format_number_with_spec(value: &str, fmt: &str) -> String {
if let Ok(n) = value.parse::<f64>() {
return sprintf::sprintf!(fmt, n).unwrap_or_else(|_| value.to_string());
}
value.to_string()
}
pub fn apply_label_template(
breaks: &[ArrayElement],
template: &str,
existing: &Option<HashMap<String, Option<String>>>,
) -> HashMap<String, Option<String>> {
let mut result = existing.clone().unwrap_or_default();
let placeholders = parse_placeholders(template);
let has_plain = placeholders
.iter()
.any(|p| matches!(p.placeholder, Placeholder::Plain))
|| placeholders.is_empty();
let numeric_precision = if has_plain {
compute_numeric_precision(breaks)
} else {
None
};
for elem in breaks {
if matches!(elem, ArrayElement::Null) {
continue;
}
let key = elem.to_key_string();
result.entry(key.clone()).or_insert_with(|| {
Some(format_value(
&key,
template,
&placeholders,
numeric_precision,
))
});
}
result
}
fn compute_numeric_precision(breaks: &[ArrayElement]) -> Option<usize> {
let tol = f64::EPSILON.sqrt();
let mut numbers: Vec<f64> = breaks
.iter()
.filter_map(|e| match e {
ArrayElement::Number(n) => Some(*n),
_ => None,
})
.collect();
if numbers.len() < 2 {
return None;
}
numbers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let smallest_diff = numbers
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.filter(|d| *d > 0.0)
.fold(f64::INFINITY, f64::min);
if smallest_diff == f64::INFINITY || smallest_diff < tol {
return None;
}
let mut precision = 10f64.powf(smallest_diff.log10().floor() - 1.0);
if numbers
.iter()
.all(|&x| ((x / precision).round() % 10.0).abs() < tol)
{
precision *= 10.0;
}
let n_decimals = (-precision.log10().floor()) as isize;
if n_decimals > 0 {
Some(n_decimals.min(20) as usize)
} else {
None
}
}
pub fn format_dataframe_column(
df: &crate::DataFrame,
column_name: &str,
template: &str,
) -> Result<crate::DataFrame, String> {
use crate::array_util::{as_f64, as_str, cast_array, new_str_array};
use arrow::array::Array;
use arrow::datatypes::DataType;
let column = df
.column(column_name)
.map_err(|e| format!("Column '{}' not found: {}", column_name, e))?;
let string_values: Vec<Option<String>> = if let Ok(str_col) = as_str(column) {
(0..str_col.len())
.map(|i| {
if str_col.is_null(i) {
None
} else {
Some(str_col.value(i).to_string())
}
})
.collect()
} else if let Ok(cast) = cast_array(column, &DataType::Float64) {
use crate::plot::format_number;
let f64_col = as_f64(&cast).map_err(|e| format!("Failed to cast column to f64: {}", e))?;
(0..f64_col.len())
.map(|i| {
if f64_col.is_null(i) {
None
} else {
Some(format_number(f64_col.value(i)))
}
})
.collect()
} else {
return Err(format!(
"Formatting doesn't support type {:?} in column '{}'. Try string or numeric types instead.",
column.data_type(),
column_name
));
};
let placeholders = parse_placeholders(template);
let formatted_owned: Vec<Option<String>> = string_values
.into_iter()
.map(|opt| opt.map(|s| format_value(&s, template, &placeholders, None)))
.collect();
let formatted_refs: Vec<Option<&str>> =
formatted_owned.iter().map(|opt| opt.as_deref()).collect();
let formatted_col = new_str_array(formatted_refs);
df.with_column(column_name, formatted_col)
.map_err(|e| format!("Failed to replace column: {}", e))
}
fn format_value(
value: &str,
template: &str,
placeholders: &[ParsedPlaceholder],
precision: Option<usize>,
) -> String {
if placeholders.is_empty() {
template.to_string()
} else {
let mut result = template.to_string();
for parsed in placeholders.iter().rev() {
let transformed = apply_transformation(value, &parsed.placeholder, precision);
result = result.replace(&parsed.match_text, &transformed);
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plain_placeholder() {
let breaks = vec![
ArrayElement::Number(0.0),
ArrayElement::Number(25.0),
ArrayElement::Number(50.0),
];
let result = apply_label_template(&breaks, "{} units", &None);
assert_eq!(result.get("0"), Some(&Some("0 units".to_string())));
assert_eq!(result.get("25"), Some(&Some("25 units".to_string())));
assert_eq!(result.get("50"), Some(&Some("50 units".to_string())));
}
#[test]
fn test_upper_placeholder() {
let breaks = vec![
ArrayElement::String("north".to_string()),
ArrayElement::String("south".to_string()),
];
let result = apply_label_template(&breaks, "{:UPPER}", &None);
assert_eq!(result.get("north"), Some(&Some("NORTH".to_string())));
assert_eq!(result.get("south"), Some(&Some("SOUTH".to_string())));
}
#[test]
fn test_lower_placeholder() {
let breaks = vec![
ArrayElement::String("HELLO".to_string()),
ArrayElement::String("WORLD".to_string()),
];
let result = apply_label_template(&breaks, "{:lower}", &None);
assert_eq!(result.get("HELLO"), Some(&Some("hello".to_string())));
assert_eq!(result.get("WORLD"), Some(&Some("world".to_string())));
}
#[test]
fn test_title_placeholder() {
let breaks = vec![
ArrayElement::String("us east".to_string()),
ArrayElement::String("eu west".to_string()),
];
let result = apply_label_template(&breaks, "Region: {:Title}", &None);
assert_eq!(
result.get("us east"),
Some(&Some("Region: Us East".to_string()))
);
assert_eq!(
result.get("eu west"),
Some(&Some("Region: Eu West".to_string()))
);
}
#[test]
fn test_datetime_placeholder() {
let breaks = vec![
ArrayElement::String("2024-01-15".to_string()),
ArrayElement::String("2024-02-15".to_string()),
];
let result = apply_label_template(&breaks, "{:time %b %Y}", &None);
assert_eq!(
result.get("2024-01-15"),
Some(&Some("Jan 2024".to_string()))
);
assert_eq!(
result.get("2024-02-15"),
Some(&Some("Feb 2024".to_string()))
);
}
#[test]
fn test_explicit_takes_priority() {
let breaks = vec![
ArrayElement::String("A".to_string()),
ArrayElement::String("B".to_string()),
ArrayElement::String("C".to_string()),
];
let mut existing = HashMap::new();
existing.insert("A".to_string(), Some("Alpha".to_string()));
let result = apply_label_template(&breaks, "Category {}", &Some(existing));
assert_eq!(result.get("A"), Some(&Some("Alpha".to_string())));
assert_eq!(result.get("B"), Some(&Some("Category B".to_string())));
assert_eq!(result.get("C"), Some(&Some("Category C".to_string())));
}
#[test]
fn test_multiple_placeholders() {
let breaks = vec![ArrayElement::String("hello".to_string())];
let result = apply_label_template(&breaks, "{} - {:UPPER}", &None);
assert_eq!(
result.get("hello"),
Some(&Some("hello - HELLO".to_string()))
);
}
#[test]
fn test_no_placeholder_literal() {
let breaks = vec![
ArrayElement::String("A".to_string()),
ArrayElement::String("B".to_string()),
];
let result = apply_label_template(&breaks, "Constant Label", &None);
assert_eq!(result.get("A"), Some(&Some("Constant Label".to_string())));
assert_eq!(result.get("B"), Some(&Some("Constant Label".to_string())));
}
#[test]
fn test_to_key_string_number_integer() {
assert_eq!(ArrayElement::Number(0.0).to_key_string(), "0");
assert_eq!(ArrayElement::Number(25.0).to_key_string(), "25");
assert_eq!(ArrayElement::Number(-100.0).to_key_string(), "-100");
}
#[test]
fn test_to_key_string_number_decimal() {
assert_eq!(ArrayElement::Number(25.5).to_key_string(), "25.5");
assert_eq!(ArrayElement::Number(0.123).to_key_string(), "0.123");
}
#[test]
fn test_to_title_case() {
assert_eq!(to_title_case("hello world"), "Hello World");
assert_eq!(to_title_case("HELLO WORLD"), "Hello World");
assert_eq!(to_title_case("hello"), "Hello");
assert_eq!(to_title_case(""), "");
}
#[test]
fn test_datetime_with_time() {
let breaks = vec![ArrayElement::String("2024-01-15T10:30:00".to_string())];
let result = apply_label_template(&breaks, "{:time %Y-%m-%d %H:%M}", &None);
assert_eq!(
result.get("2024-01-15T10:30:00"),
Some(&Some("2024-01-15 10:30".to_string()))
);
}
#[test]
fn test_invalid_datetime_fallback() {
let breaks = vec![ArrayElement::String("not-a-date".to_string())];
let result = apply_label_template(&breaks, "{:time %Y-%m-%d}", &None);
assert_eq!(
result.get("not-a-date"),
Some(&Some("not-a-date".to_string()))
);
}
#[test]
fn test_null_skipped() {
let breaks = vec![
ArrayElement::String("A".to_string()),
ArrayElement::Null,
ArrayElement::String("B".to_string()),
];
let result = apply_label_template(&breaks, "{}", &None);
assert_eq!(result.len(), 2);
assert!(result.contains_key("A"));
assert!(result.contains_key("B"));
}
#[test]
fn test_number_format_decimal_places() {
let breaks = vec![ArrayElement::Number(25.5), ArrayElement::Number(100.0)];
let result = apply_label_template(&breaks, "${:num %.2f}", &None);
assert_eq!(result.get("25.5"), Some(&Some("$25.50".to_string())));
assert_eq!(result.get("100"), Some(&Some("$100.00".to_string())));
}
#[test]
fn test_number_format_no_decimals() {
let breaks = vec![ArrayElement::Number(25.7)];
let result = apply_label_template(&breaks, "{:num %.0f} items", &None);
assert_eq!(result.get("25.7"), Some(&Some("26 items".to_string())));
}
#[test]
fn test_number_format_scientific() {
let breaks = vec![ArrayElement::Number(1234.5)];
let result = apply_label_template(&breaks, "{:num %.2e}", &None);
assert_eq!(result.get("1234.5"), Some(&Some("1.23e+03".to_string())));
}
#[test]
fn test_number_format_non_numeric_fallback() {
let breaks = vec![ArrayElement::String("hello".to_string())];
let result = apply_label_template(&breaks, "{:num %.2f}", &None);
assert_eq!(result.get("hello"), Some(&Some("hello".to_string())));
}
#[test]
fn test_number_format_integer() {
let breaks = vec![ArrayElement::Number(42.0)];
let result = apply_label_template(&breaks, "{:num %d}", &None);
assert_eq!(result.get("42"), Some(&Some("42".to_string())));
}
#[test]
fn test_plain_placeholder_consistent_decimal_precision() {
let breaks = vec![
ArrayElement::Number(54.5),
ArrayElement::Number(55.0),
ArrayElement::Number(55.5),
];
let result = apply_label_template(&breaks, "{}", &None);
assert_eq!(result.get("54.5"), Some(&Some("54.5".to_string())));
assert_eq!(result.get("55"), Some(&Some("55.0".to_string())));
assert_eq!(result.get("55.5"), Some(&Some("55.5".to_string())));
}
#[test]
fn test_plain_placeholder_all_integers_no_decimals() {
let breaks = vec![
ArrayElement::Number(10.0),
ArrayElement::Number(20.0),
ArrayElement::Number(30.0),
];
let result = apply_label_template(&breaks, "{}", &None);
assert_eq!(result.get("10"), Some(&Some("10".to_string())));
assert_eq!(result.get("20"), Some(&Some("20".to_string())));
assert_eq!(result.get("30"), Some(&Some("30".to_string())));
}
#[test]
fn test_plain_placeholder_small_fractional_steps() {
let breaks = vec![
ArrayElement::Number(0.0),
ArrayElement::Number(0.05),
ArrayElement::Number(0.1),
];
let result = apply_label_template(&breaks, "{}", &None);
assert_eq!(result.get("0"), Some(&Some("0.00".to_string())));
assert_eq!(result.get("0.05"), Some(&Some("0.05".to_string())));
assert_eq!(result.get("0.1"), Some(&Some("0.10".to_string())));
}
}