use std::{
path::{Path, PathBuf},
time::SystemTime,
};
pub const DEFAULT_TEMPLATE: &str = "gem_export_{material}_{width}x{height}_{spp}spp_{timestamp}";
#[derive(Debug, Clone)]
pub struct TemplateContext {
pub design: String,
pub designer: String,
pub shape: String,
pub material: String,
pub ri: String,
pub width: u32,
pub height: u32,
pub spp: u32,
pub bounces: u32,
pub colorspace: String,
pub preset: String,
pub lighting: String,
pub yaw_deg: f32,
pub pitch_deg: f32,
pub distance: f32,
pub exposure: f32,
}
fn substitute(name: &str, ctx: &TemplateContext, now: SystemTime) -> Option<String> {
let (secs, (year, month, day, hour, minute, second)) = {
let secs = now
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
(secs, civil_from_unix_seconds(secs))
};
Some(match name {
"design" => ctx.design.clone(),
"designer" => ctx.designer.clone(),
"shape" => ctx.shape.clone(),
"material" => ctx.material.clone(),
"ri" => ctx.ri.clone(),
"width" => ctx.width.to_string(),
"height" => ctx.height.to_string(),
"spp" => ctx.spp.to_string(),
"bounces" => ctx.bounces.to_string(),
"colorspace" => ctx.colorspace.clone(),
"preset" => ctx.preset.clone(),
"lighting" => ctx.lighting.clone(),
"yaw" => format!("{:.1}", ctx.yaw_deg),
"pitch" => format!("{:.1}", ctx.pitch_deg),
"distance" => format!("{:.2}", ctx.distance),
"exposure" => format!("{:.2}", ctx.exposure),
"date" => format!("{year:04}-{month:02}-{day:02}"),
"time" => format!("{hour:02}-{minute:02}-{second:02}"),
"timestamp" => secs.to_string(),
_ => return None,
})
}
#[must_use]
pub fn render(template: &str, ctx: &TemplateContext, now: SystemTime) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after_open = &rest[open + 1..];
let Some(close) = after_open.find('}') else {
out.push('{');
out.push_str(after_open);
rest = "";
continue;
};
let name = &after_open[..close];
if let Some(value) = substitute(name, ctx, now) {
out.push_str(&value);
} else {
out.push('{');
out.push_str(name);
out.push('}');
}
rest = &after_open[close + 1..];
}
out.push_str(rest);
out
}
const fn is_forbidden_filename_char(c: char) -> bool {
matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || (c as u32) < 0x20
}
const RESERVED_WINDOWS_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
fn sanitize_filename(raw: &str) -> String {
let mut cleaned: String = raw
.chars()
.map(|c| {
if is_forbidden_filename_char(c) {
'_'
} else {
c
}
})
.collect();
while matches!(cleaned.chars().last(), Some('.' | ' ')) {
cleaned.pop();
}
if cleaned.is_empty() {
cleaned = "export".to_string();
}
let stem_before_extension = cleaned.split('.').next().unwrap_or(&cleaned);
if RESERVED_WINDOWS_NAMES
.iter()
.any(|reserved| reserved.eq_ignore_ascii_case(stem_before_extension))
{
cleaned = format!("_{cleaned}");
}
cleaned
}
fn ensure_png_extension(name: &str) -> String {
if name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(".png") {
name.to_string()
} else {
format!("{name}.png")
}
}
fn unique_path(dir: &Path, filename: &str) -> PathBuf {
let split_at = filename.len() - 4; let (stem, extension) = filename.split_at(split_at);
let mut candidate = dir.join(filename);
let mut suffix = 2u32;
while candidate.exists() {
candidate = dir.join(format!("{stem} ({suffix}){extension}"));
suffix += 1;
}
candidate
}
#[must_use]
pub fn resolve_export_path(export_dir: &Path, template: &str, ctx: &TemplateContext) -> PathBuf {
let rendered = render(template, ctx, SystemTime::now());
let sanitized = sanitize_filename(&rendered);
let named = ensure_png_extension(&sanitized);
assert!(
!named.contains('/') && !named.contains('\\'),
"sanitize_filename must strip every path separator; got {named:?}"
);
assert!(
named != "." && named != "..",
"sanitize_filename must never produce a bare '.' or '..' component; got {named:?}"
);
let resolved = unique_path(export_dir, &named);
assert_eq!(
resolved.parent(),
Some(export_dir),
"resolved export path {} must live directly inside the export directory {}",
resolved.display(),
export_dir.display(),
);
resolved
}
pub const fn civil_from_unix_seconds(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
let days = secs.div_euclid(86_400);
let time_of_day = secs.rem_euclid(86_400);
let hour = (time_of_day / 3600) as u32;
let minute = ((time_of_day % 3600) / 60) as u32;
let second = (time_of_day % 60) as u32;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
let day_of_era = (z - era * 146_097) as u64; let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; let year = year_of_era as i64 + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); let mp = (5 * day_of_year + 2) / 153; let day = (day_of_year - (153 * mp + 2) / 5 + 1) as u32; let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let year = if month <= 2 { year + 1 } else { year };
(year, month, day, hour, minute, second)
}
#[cfg(test)]
mod tests;