//! TRACES: FR-EXP-6 //! Filename templates and what to do when the name is taken. //! //! # Why the caller supplies the "does this exist" test //! //! [`resolve_name`] takes a closure rather than looking at a directory, //! because there is no directory it could look at that would work everywhere. //! A destination is a path on Linux, a Storage Access Framework tree on //! Android with no path at all (ARCH §6.9), or a folder on a Nextcloud //! server reached by PROPFIND. All three can answer "is this name taken", //! and none of them can be asked the same way. //! //! It matters most on Android, where the platform actively works against us: //! `DocumentsContract.createDocument` renames on collision *by itself*, //! appending ` (1)` and returning a URI with a name nobody asked for, and it //! cannot overwrite at all. So every one of the three [`CollisionPolicy`] //! settings requires knowing the answer before creating anything — which is //! exactly what this function is shaped for. use dr_types::{CollisionPolicy, ExportFormat}; /// What a template can refer to. #[derive(Debug, Clone, Default)] pub struct NameContext<'a> { /// The source image's name, without extension — `{name}`. pub source_stem: &'a str, /// Position in the batch, 1-based — `{seq}`. pub sequence: u32, /// Capture date as `YYYY-MM-DD` — `{date}`. Empty where unknown. pub date: &'a str, /// The export's pixel dimensions — `{dimensions}`. pub width: u32, pub height: u32, /// The preset that produced this export — `{preset}`. Empty where none. pub preset: &'a str, } /// Expand a template into a filename stem. /// /// Unknown tokens are left verbatim rather than dropped. A user who typed /// `{nmae}` should see it in the output and understand what happened; a /// silently empty filename is a puzzle, and a template that quietly loses a /// token produces a directory of files named the same thing. pub fn expand(template: &str, ctx: &NameContext<'_>) -> String { let seq = ctx.sequence.to_string(); let dimensions = format!("{}x{}", ctx.width, ctx.height); let mut out = String::with_capacity(template.len() + 16); let mut rest = template; while let Some(open) = rest.find('{') { out.push_str(&rest[..open]); let Some(close) = rest[open..].find('}') else { // An unclosed brace is literal text; there is nothing to expand // and dropping the remainder would truncate the name. Consumed // here rather than left for the tail append below, which has // already had everything before the brace taken from it. out.push_str(&rest[open..]); rest = ""; break; }; let token = &rest[open + 1..open + close]; match token { "name" => out.push_str(ctx.source_stem), "seq" => out.push_str(&seq), "date" => out.push_str(ctx.date), "dimensions" => out.push_str(&dimensions), "preset" => out.push_str(ctx.preset), _ => out.push_str(&rest[open..open + close + 1]), } rest = &rest[open + close + 1..]; } out.push_str(rest); let cleaned = sanitise(&out); if cleaned.is_empty() { // Every token was empty — a template of `{preset}` with no preset, on // an image with no date. Falling back to the source name is the one // answer that is always available and never collides more than the // source files themselves do. return sanitise(ctx.source_stem); } cleaned } /// Strip what no filesystem, SAF provider or WebDAV server will take. /// /// The intersection of three sets of rules rather than any one of them: an /// export written to a Nextcloud folder may later sync down to a Windows /// client, and a name that was legal where it was created is not much comfort /// on the machine that cannot open it. fn sanitise(stem: &str) -> String { let mut out: String = stem .chars() .map(|c| match c { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', c if (c as u32) < 0x20 => '-', c => c, }) .collect(); // Trailing dots and spaces are legal on Linux and rejected by Windows, // and a name ending in one is almost always an accident of a template // whose last token expanded to nothing. while out.ends_with('.') || out.ends_with(' ') { out.pop(); } out.trim_start().to_string() } /// The filename this export should be written under, honouring the collision /// policy. /// /// `taken` answers whether a name already exists in the destination. Returns /// `None` for [`CollisionPolicy::Skip`] when the name is in use — the caller /// writes nothing and moves on, which is the whole point of that setting. pub fn resolve_name( template: &str, ctx: &NameContext<'_>, format: ExportFormat, collision: CollisionPolicy, taken: &dyn Fn(&str) -> bool, ) -> Option { let stem = expand(template, ctx); let ext = format.extension(); let first = format!("{stem}.{ext}"); if !taken(&first) { return Some(first); } match collision { CollisionPolicy::Overwrite => Some(first), CollisionPolicy::Skip => None, CollisionPolicy::Increment => { // Bounded. An unbounded search would spin forever against a // destination that reports everything as taken — a permission // error misread as existence, say — and a batch that hangs is // worse than one that reports a failure. for n in 1..10_000 { let candidate = format!("{stem}-{n}.{ext}"); if !taken(&candidate) { return Some(candidate); } } log::warn!("{stem}: ten thousand names taken; skipping"); None } } } #[cfg(test)] mod tests { use super::*; fn ctx() -> NameContext<'static> { NameContext { source_stem: "IMG_1234", sequence: 7, date: "2026-08-16", width: 2048, height: 1365, preset: "Web", } } fn free(_: &str) -> bool { false } #[test] fn the_default_template_is_the_source_name() { assert_eq!(expand("{name}", &ctx()), "IMG_1234"); } #[test] fn every_documented_token_expands() { // The settings page advertises these five in its hint; a token listed // there and unhandled here would reach the filename verbatim. assert_eq!(expand("{name}", &ctx()), "IMG_1234"); assert_eq!(expand("{seq}", &ctx()), "7"); assert_eq!(expand("{date}", &ctx()), "2026-08-16"); assert_eq!(expand("{dimensions}", &ctx()), "2048x1365"); assert_eq!(expand("{preset}", &ctx()), "Web"); } #[test] fn tokens_combine_with_literal_text() { assert_eq!( expand("{date}_{name}_{dimensions}", &ctx()), "2026-08-16_IMG_1234_2048x1365" ); } #[test] fn an_unknown_token_survives_verbatim() { // A typo the user can see and fix, rather than a name that silently // lost a component and now collides with every other export. assert_eq!(expand("{nmae}-x", &ctx()), "{nmae}-x"); } #[test] fn an_unclosed_brace_is_literal_text() { assert_eq!(expand("{name", &ctx()), "{name"); assert_eq!(expand("a{name}b{", &ctx()), "aIMG_1234b{"); } #[test] fn a_template_that_expands_to_nothing_falls_back_to_the_source_name() { // `{preset}` with no preset selected. An empty filename is not a file. let mut c = ctx(); c.preset = ""; assert_eq!(expand("{preset}", &c), "IMG_1234"); } #[test] fn path_separators_cannot_escape_the_destination() { // `{name}` comes from a source filename, and a template is user text. // Either could carry a slash, and an export must not write outside // the folder that was chosen — nor create a subfolder on the server. let mut c = ctx(); c.source_stem = "holiday/2026"; assert_eq!(expand("{name}", &c), "holiday-2026"); assert_eq!(expand("../../etc/passwd", &ctx()), "..-..-etc-passwd"); } #[test] fn characters_windows_rejects_are_replaced() { // An export may sync down to a Windows client through Nextcloud, and // a name that was legal where it was written is no comfort there. assert_eq!(expand(r#"a:b*c?d"eg|h\i"#, &ctx()), "a-b-c-d-e-f-g-h-i"); } #[test] fn trailing_dots_and_spaces_are_trimmed() { let mut c = ctx(); c.preset = ""; assert_eq!(expand("{name}.{preset}", &c), "IMG_1234"); assert_eq!(expand("{name} ", &ctx()), "IMG_1234"); } #[test] fn a_free_name_is_used_as_is() { let got = resolve_name( "{name}", &ctx(), ExportFormat::Jpeg, CollisionPolicy::Increment, &free, ); assert_eq!(got.as_deref(), Some("IMG_1234.jpg")); } #[test] fn the_extension_follows_the_format() { for (format, ext) in [ (ExportFormat::Jpeg, "jpg"), (ExportFormat::Png, "png"), (ExportFormat::Tiff16, "tif"), ] { let got = resolve_name("{name}", &ctx(), format, CollisionPolicy::Skip, &free); assert_eq!(got.as_deref(), Some(&*format!("IMG_1234.{ext}"))); } } #[test] fn increment_finds_the_first_free_suffix() { let taken = |n: &str| matches!(n, "IMG_1234.jpg" | "IMG_1234-1.jpg" | "IMG_1234-2.jpg"); let got = resolve_name( "{name}", &ctx(), ExportFormat::Jpeg, CollisionPolicy::Increment, &taken, ); assert_eq!(got.as_deref(), Some("IMG_1234-3.jpg")); } #[test] fn skip_returns_nothing_when_the_name_is_taken() { // The caller writes no file at all — that is what Skip means, and it // is why this returns an Option rather than always a name. let got = resolve_name( "{name}", &ctx(), ExportFormat::Jpeg, CollisionPolicy::Skip, &|_| true, ); assert_eq!(got, None); } #[test] fn overwrite_returns_the_taken_name() { let got = resolve_name( "{name}", &ctx(), ExportFormat::Jpeg, CollisionPolicy::Overwrite, &|_| true, ); assert_eq!(got.as_deref(), Some("IMG_1234.jpg")); } #[test] fn increment_gives_up_rather_than_spinning_forever() { // A destination that reports every name as taken — a permission error // misread as existence — must not hang the batch. let got = resolve_name( "{name}", &ctx(), ExportFormat::Jpeg, CollisionPolicy::Increment, &|_| true, ); assert_eq!(got, None); } }