use crate::base::structs::DownloadMeta;
use crate::error::Result;
use chrono::{DateTime, Utc};
use handlebars::Handlebars;
use percent_encoding::percent_decode;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct TemplateRenderer {
registry: Handlebars<'static>,
}
impl Default for TemplateRenderer {
fn default() -> Self {
Self::new()
}
}
impl TemplateRenderer {
pub fn new() -> Self {
let mut registry = Handlebars::new();
registry.register_escape_fn(|s| s.into());
registry.register_helper("date_format", Box::new(date_format_helper));
registry.register_helper("file_extension", Box::new(file_extension_helper));
registry.register_helper("format_size", Box::new(format_size_helper));
registry.register_helper("url_decode", Box::new(url_decode_helper));
registry.register_helper("uppercase", Box::new(uppercase_helper));
registry.register_helper("lowercase", Box::new(lowercase_helper));
registry.register_helper("filename_stem", Box::new(filename_stem_helper));
Self { registry }
}
pub fn render_path_template(
&self,
template: &str,
context: &TemplateContext,
) -> Result<String> {
if template.is_empty() {
return Ok(context.filename.to_string());
}
let mut data = serde_json::json!({
"url": context.url,
"domain": context.domain.unwrap_or("unknown"),
"filename": context.filename,
"ext": context.extension.unwrap_or(""),
"size": context.meta.expected_size.unwrap_or(0),
"content_type": context.meta.content_type.as_deref().unwrap_or(""),
"date": context.download_time.format("%Y-%m-%d").to_string(),
"time": context.download_time.format("%H-%M-%S").to_string(),
});
if let Some(ref custom) = context.custom_data {
for (k, v) in custom {
data[k] = serde_json::Value::String(v.clone());
}
}
self.registry
.render_template(template, &data)
.map_err(|e| format!("Template rendering error: {}", e).into())
}
pub fn validate_template(&self, template: &str) -> Result<()> {
if template.is_empty() {
return Ok(());
}
let dummy_data = serde_json::json!({
"url": "",
"domain": "",
"filename": "",
"ext": "",
"size": 0,
"content_type": "",
"date": "",
"time": "",
});
let context = handlebars::Context::wraps(&dummy_data)
.map_err(|e| crate::error::Error::from(format!("Template validation failed: {}", e)))?;
self.registry
.render_template_with_context(template, &context)
.map_err(|e| crate::error::Error::from(format!("Invalid template: {}", e)))?;
Ok(())
}
pub fn registry(&self) -> &Handlebars<'static> {
&self.registry
}
}
#[derive(Debug, Clone)]
pub struct TemplateContext<'a> {
pub url: &'a str,
pub domain: Option<&'a str>,
pub filename: &'a str,
pub extension: Option<&'a str>,
pub meta: &'a DownloadMeta,
pub download_time: DateTime<Utc>,
pub custom_data: Option<HashMap<String, String>>,
}
impl<'a> TemplateContext<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
url: &'a str,
domain: Option<&'a str>,
filename: &'a str,
extension: Option<&'a str>,
meta: &'a DownloadMeta,
download_time: DateTime<Utc>,
) -> Self {
Self {
url,
domain,
filename,
extension,
meta,
download_time,
custom_data: None,
}
}
pub fn with_custom_data(mut self, custom_data: HashMap<String, String>) -> Self {
self.custom_data = Some(custom_data);
self
}
pub fn add_custom_data(mut self, key: String, value: String) -> Self {
self.custom_data
.get_or_insert_with(HashMap::new)
.insert(key, value);
self
}
}
fn date_format_helper(
h: &handlebars::Helper<'_>,
_: &Handlebars<'_>,
_: &handlebars::Context,
_: &mut handlebars::RenderContext<'_, '_>,
out: &mut dyn handlebars::Output,
) -> handlebars::HelperResult {
let format = h
.param(1)
.and_then(|v| v.value().as_str())
.unwrap_or("%Y-%m-%d");
let timestamp = h.param(0).and_then(|v| v.value().as_str()).unwrap_or("");
if timestamp.is_empty() {
return Ok(out.write("")?);
}
let dt = DateTime::parse_from_rfc3339(timestamp)
.or_else(|_| DateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S"))
.unwrap_or_default();
out.write(&dt.format(format).to_string())?;
Ok(())
}
fn file_extension_helper(
h: &handlebars::Helper<'_>,
_: &Handlebars<'_>,
_: &handlebars::Context,
_: &mut handlebars::RenderContext<'_, '_>,
out: &mut dyn handlebars::Output,
) -> handlebars::HelperResult {
let filename = h.param(0).and_then(|v| v.value().as_str()).unwrap_or("");
let ext = Path::new(filename)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
out.write(ext)?;
Ok(())
}
fn format_size_helper(
h: &handlebars::Helper<'_>,
_: &Handlebars<'_>,
_: &handlebars::Context,
_: &mut handlebars::RenderContext<'_, '_>,
out: &mut dyn handlebars::Output,
) -> handlebars::HelperResult {
let size = h.param(0).and_then(|v| v.value().as_u64()).unwrap_or(0);
let formatted = format_size(size);
out.write(&formatted)?;
Ok(())
}
fn url_decode_helper(
h: &handlebars::Helper<'_>,
_: &Handlebars<'_>,
_: &handlebars::Context,
_: &mut handlebars::RenderContext<'_, '_>,
out: &mut dyn handlebars::Output,
) -> handlebars::HelperResult {
let encoded = h.param(0).and_then(|v| v.value().as_str()).unwrap_or("");
let decoded = percent_decode(encoded.as_bytes())
.decode_utf8()
.unwrap_or(std::borrow::Cow::Borrowed(encoded))
.to_string();
out.write(&decoded)?;
Ok(())
}
fn uppercase_helper(
h: &handlebars::Helper<'_>,
_: &Handlebars<'_>,
_: &handlebars::Context,
_: &mut handlebars::RenderContext<'_, '_>,
out: &mut dyn handlebars::Output,
) -> handlebars::HelperResult {
let input = h.param(0).and_then(|v| v.value().as_str()).unwrap_or("");
let result = input.to_uppercase();
out.write(&result)?;
Ok(())
}
fn lowercase_helper(
h: &handlebars::Helper<'_>,
_: &Handlebars<'_>,
_: &handlebars::Context,
_: &mut handlebars::RenderContext<'_, '_>,
out: &mut dyn handlebars::Output,
) -> handlebars::HelperResult {
let input = h.param(0).and_then(|v| v.value().as_str()).unwrap_or("");
let result = input.to_lowercase();
out.write(&result)?;
Ok(())
}
fn filename_stem_helper(
h: &handlebars::Helper<'_>,
_: &Handlebars<'_>,
_: &handlebars::Context,
_: &mut handlebars::RenderContext<'_, '_>,
out: &mut dyn handlebars::Output,
) -> handlebars::HelperResult {
let filename = h.param(0).and_then(|v| v.value().as_str()).unwrap_or("");
let path = Path::new(filename);
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(filename);
out.write(stem)?;
Ok(())
}
fn format_size(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
const THRESHOLD: f64 = 1024.0;
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= THRESHOLD && unit_index < UNITS.len() - 1 {
size /= THRESHOLD;
unit_index += 1;
}
if unit_index == 0 {
format!("{} {}", bytes, UNITS[unit_index])
} else {
format!("{:.2} {}", size, UNITS[unit_index])
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn create_test_meta() -> DownloadMeta {
DownloadMeta {
expected_size: Some(1024 * 1024), content_type: Some("application/octet-stream".to_string()),
suggested_filename: Some("test.zip".to_string()),
etag: None,
last_modified: None,
download_start: Some(Utc::now()),
checksum: None,
}
}
#[test]
fn test_template_renderer_new() {
let renderer = TemplateRenderer::new();
assert!(renderer.validate_template("{{filename}}").is_ok());
}
#[test]
fn test_template_renderer_default() {
let renderer1 = TemplateRenderer::new();
let renderer2 = TemplateRenderer::default();
assert!(renderer1.validate_template("{{filename}}").is_ok());
assert!(renderer2.validate_template("{{filename}}").is_ok());
}
#[test]
fn test_validate_template() {
let renderer = TemplateRenderer::new();
assert!(renderer.validate_template("{{filename}}").is_ok());
assert!(renderer.validate_template("{{date}}/{{filename}}").is_ok());
assert!(renderer.validate_template("").is_ok());
assert!(renderer.validate_template("{{filename").is_err());
}
#[test]
fn test_render_path_template_empty() {
let renderer = TemplateRenderer::new();
let meta = create_test_meta();
let now = Utc::now();
let context = TemplateContext::new(
"https://example.com/file.zip",
Some("example.com"),
"file.zip",
Some("zip"),
&meta,
now,
);
let result = renderer.render_path_template("", &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "file.zip");
}
#[test]
fn test_render_path_template_basic() {
let renderer = TemplateRenderer::new();
let meta = create_test_meta();
let now = Utc::now();
let context = TemplateContext::new(
"https://example.com/file.zip",
Some("example.com"),
"file.zip",
Some("zip"),
&meta,
now,
);
let result = renderer.render_path_template("{{filename}}", &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "file.zip");
let result = renderer.render_path_template("{{domain}}", &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "example.com");
let result = renderer.render_path_template("{{ext}}", &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "zip");
}
#[test]
fn test_render_path_template_complex() {
let renderer = TemplateRenderer::new();
let meta = create_test_meta();
let now = Utc::now();
let context = TemplateContext::new(
"https://example.com/file.zip",
Some("example.com"),
"file.zip",
Some("zip"),
&meta,
now,
);
let template = "{{date}}/{{domain}}/{{filename}}";
let result = renderer.render_path_template(template, &context);
assert!(result.is_ok());
let rendered = result.unwrap();
assert!(rendered.contains("example.com"));
assert!(rendered.contains("file.zip"));
}
#[test]
fn test_template_context_builder() {
let meta = create_test_meta();
let now = Utc::now();
let context = TemplateContext::new(
"https://example.com/file.zip",
Some("example.com"),
"file.zip",
Some("zip"),
&meta,
now,
)
.add_custom_data("resource_id".to_string(), "12345".to_string());
assert_eq!(context.url, "https://example.com/file.zip");
assert_eq!(context.domain, Some("example.com"));
assert_eq!(context.filename, "file.zip");
assert!(context.custom_data.is_some());
assert_eq!(
context.custom_data.unwrap().get("resource_id"),
Some(&"12345".to_string())
);
}
#[test]
fn test_format_size() {
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(1024), "1.00 KB");
assert_eq!(format_size(1024 * 1024), "1.00 MB");
assert_eq!(format_size(1024 * 1024 * 100), "100.00 MB");
assert_eq!(format_size(1024 * 1024 * 1024), "1.00 GB");
}
#[test]
fn test_render_with_custom_data() {
let renderer = TemplateRenderer::new();
let meta = create_test_meta();
let now = Utc::now();
let mut custom_data = HashMap::new();
custom_data.insert("category".to_string(), "documents".to_string());
let context = TemplateContext::new(
"https://example.com/file.pdf",
Some("example.com"),
"file.pdf",
Some("pdf"),
&meta,
now,
)
.with_custom_data(custom_data);
let result = renderer.render_path_template("{{category}}/{{filename}}", &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "documents/file.pdf");
}
#[test]
fn test_render_missing_domain() {
let renderer = TemplateRenderer::new();
let meta = create_test_meta();
let now = Utc::now();
let context = TemplateContext::new(
"https://example.com/file.zip",
None, "file.zip",
Some("zip"),
&meta,
now,
);
let result = renderer.render_path_template("{{domain}}", &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "unknown");
}
}