refactor(lightcraft-engine): split rename.rs into rename/

This commit is contained in:
aiongg
2026-10-26 21:39:55 +00:00
parent 8942984c35
commit 39d6a8d668
6 changed files with 1176 additions and 1137 deletions
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
use super::sidecar::PacketVersion;
use super::*;
/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`).
pub(super) fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> {
let mut v = Vec::new();
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
let p = crate::sidecar::sidecar_path(path, naming);
v.push((p.with_extension("XMP"), naming, true));
v.push((p, naming, false));
}
v
}
/// The file-system primitives renaming uses. [`RealFs`] is the disk; tests substitute a model of
/// a case-insensitive or case-sensitive volume (and inject failures).
pub(crate) trait MoveFs {
/// Something (file, folder, link) answers to `p`.
fn exists(&self, p: &Path) -> bool;
fn is_file(&self, p: &Path) -> bool;
/// `a` and `b` name the same file (e.g. `IMG.JPG` and `img.jpg` on a case-insensitive
/// volume). Decided by file identity, never by comparing names: on a case-sensitive volume
/// they are two different photos. When in doubt: `false`.
fn same_file(&self, a: &Path, b: &Path) -> bool;
/// Rename `a` to `b`, failing (never replacing) when `b` exists.
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>;
/// Copy `a` to a new file `b` (an independent copy, never a link), failing when `b` exists.
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>;
fn remove_file(&self, p: &Path) -> std::io::Result<()>;
/// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g.
/// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar.
fn stem_sibling(&self, sidecar: &Path) -> Option<String>;
/// The folder lists an entry spelled exactly like `p` (letter case included). On a
/// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two
/// apart. When the folder can't be listed: `true`, so nothing is skipped.
fn listed(&self, p: &Path) -> bool {
let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true };
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
match std::fs::read_dir(dir) {
Ok(entries) => entries.flatten().any(|e| e.file_name() == name),
Err(_) => true,
}
}
/// Content observed in a packet, independent of its pathname or inode. Model volumes
/// override this; disk implementations stream a bounded buffer without retaining the XML.
fn packet_version(&self, path: &Path) -> std::io::Result<PacketVersion> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut hash = lightcraft_preview::Hasher128::new();
let mut len = 0u64;
let mut buffer = [0u8; 16 * 1024];
loop {
let n = match file.read(&mut buffer) {
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
result => result?,
};
if n == 0 {
return Ok(PacketVersion { len, hash: hash.finish() });
}
let bytes = buffer.get(..n).ok_or_else(|| std::io::Error::other("invalid sidecar read length"))?;
len = len.checked_add(n as u64).ok_or_else(|| std::io::Error::other("sidecar is too large"))?;
hash.update(bytes);
}
}
}
/// The real file system.
pub(crate) struct RealFs;
impl MoveFs for RealFs {
fn exists(&self, p: &Path) -> bool {
std::fs::symlink_metadata(p).is_ok()
}
fn is_file(&self, p: &Path) -> bool {
p.is_file()
}
fn same_file(&self, a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else { return false };
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
ma.dev() == mb.dev() && ma.ino() == mb.ino()
}
#[cfg(not(unix))]
{
// no stable file id in std here: `a` and `b` are one file under two spellings when
// they look alike and the folder doesn't list both (on a case-sensitive folder
// holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the
// listed one: the answer must not depend on the argument order.
let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false };
let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase();
if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
return false;
}
let Ok(entries) = std::fs::read_dir(dir) else { return false };
let (mut listed_a, mut listed_b) = (false, false);
for e in entries.flatten() {
let name = e.file_name();
listed_a |= name == name_a;
listed_b |= name == name_b;
}
!(listed_a && listed_b)
}
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.exists(b) {
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display())));
}
// A hard link fails when `b` exists — even if it appeared since the check above — so
// nothing is ever replaced. Volumes without hard links (FAT, some network shares) fall back
// to a plain rename after the check.
match std::fs::hard_link(a, b) {
Ok(()) => std::fs::remove_file(a).inspect_err(|_| {
// both names are the same file: dropping the new one loses nothing
let _ = std::fs::remove_file(b);
}),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
Err(_) => std::fs::rename(a, b)
.or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }),
}
}
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
if self.exists(b) {
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display())));
}
// Streamed (a large raw or video is never read into memory whole), into a hidden name
// beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so
// the partial file can't be removed either, leaves no truncated file under the photo's
// name for a retry to take for the photo.
let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
// a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't
// block the next
let partial = (0..1000u32)
.map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id())))
.find(|p| !self.exists(p))
.ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?;
let copied = (|| {
let mut from = std::fs::File::open(a)?;
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?;
std::io::copy(&mut from, &mut f)?;
f.sync_all()
})();
let placed = copied.and_then(|()| self.rename_no_replace(&partial, b));
if placed.is_err() {
let _ = std::fs::remove_file(&partial);
}
placed
}
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
std::fs::remove_file(p)
}
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
crate::import_move::sibling_photo(sidecar)
}
}
/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original
/// byte for byte, and only then remove the original. A copy that doesn't match is removed.
fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> {
fs.copy_no_replace(a, b)?;
if !same_contents(a, b).unwrap_or(false) {
let _ = std::fs::remove_file(b);
return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display())));
}
std::fs::remove_file(a).inspect_err(|_| {
// the original stays: drop the copy so the file isn't in two places
let _ = std::fs::remove_file(b);
})
}
/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so
/// a large raw or video is never read into memory whole.
pub(super) fn same_contents(a: &Path, b: &Path) -> std::io::Result<bool> {
const CHUNK: usize = 1 << 20;
let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?);
if fa.metadata()?.len() != fb.metadata()?.len() {
return Ok(false);
}
let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]);
loop {
let n = read_full(&mut fa, &mut ba)?;
let m = read_full(&mut fb, &mut bb)?;
if n != m || ba.get(..n) != bb.get(..m) {
return Ok(false);
}
if n == 0 {
return Ok(true);
}
}
}
/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer
/// only at the end of the file).
fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result<usize> {
let mut filled = 0;
while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) {
match r.read(rest) {
Ok(0) => break,
Ok(k) => filled = filled.saturating_add(k),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(filled)
}
/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive
/// volume): the rename must go through a temporary name.
pub(super) fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool {
from != to && folded_path(&from.to_string_lossy()) == folded_path(&to.to_string_lossy()) && fs.exists(to) && fs.same_file(from, to)
}
/// Windows accepts both separators; spelling differences must not hide file identity.
pub(super) fn folded_path(path: &str) -> String {
if cfg!(windows) { path.replace('\\', "/").to_lowercase() } else { path.to_lowercase() }
}
/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a
/// unique temporary name so case-insensitive volumes see a real change.
pub(super) fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> {
if !is_respelling(fs, a, b) {
return fs.rename_no_replace(a, b);
}
let tmp = (0..1000u32)
.map(|k| a.with_file_name(format!(".lc-rename-{}-{k}", std::process::id())))
.find(|t| !fs.exists(t))
.ok_or_else(|| std::io::Error::other("no free temporary name"))?;
fs.rename_no_replace(a, &tmp)?;
fs.rename_no_replace(&tmp, b).map_err(|e| match fs.rename_no_replace(&tmp, a) {
Ok(()) => e,
Err(e2) => std::io::Error::other(format!("{e}; it could not be given its old name back either ({e2}): it is now {}", tmp.display())),
})
}
+168
View File
@@ -0,0 +1,168 @@
use super::fs::{MoveFs, RealFs, folded_path, is_respelling, move_one, sidecars};
use super::sidecar::{RetainedPacket, SidecarMove, advance_packets, note_companions, packet_aliases};
use super::*;
/// Why [`move_file`] failed, and what it left behind.
#[derive(Clone, Debug, PartialEq)]
pub struct MoveError {
/// What went wrong, including every step that could not be undone and where that file is now.
pub message: String,
/// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file
/// back failed too. Its sidecars that did move stay with it.
pub moved: bool,
pub(crate) pending_sidecars: Vec<SidecarMove>,
}
impl std::fmt::Display for MoveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
/// Move `from` to `to` (never over an existing file) together with its sidecars. On error the
/// moves are undone; whatever can't be undone is named in the error (see [`MoveError`]).
pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> {
move_file_with(&RealFs, from, to)
}
/// [`move_file`] on the file system `fs`.
pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::result::Result<(), MoveError> {
if Path::new(from) == Path::new(to) {
return Ok(());
}
let fail = |message: String| MoveError { message, moved: false, pending_sidecars: Vec::new() };
let (f, t) = (Path::new(from), Path::new(to));
// `to` may be this very file spelled differently (case-insensitive volume); any other file
// there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced
if fs.exists(t) && !is_respelling(fs, f, t) {
return Err(fail(format!("{to} already exists")));
}
// An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the
// lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`.
let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc));
let mut packets: Vec<RetainedPacket> =
sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect();
move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?;
let mut done: Vec<SidecarMove> = Vec::new();
let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| {
let mut dst = crate::sidecar::sidecar_path(to, naming);
if upper {
dst = dst.with_extension("XMP");
}
// A stem sidecar another photo still uses is copied, not taken away (issue #92).
let copied = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some();
SidecarMove { from: sc, to: dst, copied, companions: Vec::new() }
});
while let Some(m) = remaining.next() {
if !m.needed(fs) {
continue; // never overwrite: the old sidecar stays where it was
}
let aliases = packet_aliases(&packets, fs, &m.from);
if let Err(e) = m.apply(fs) {
let mut message = format!("rename {}: {e}", m.from.display());
// The file first: if it can't go back, sidecars that moved stay with it.
if let Err(e) = move_one(fs, t, f) {
message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}"));
if !done.is_empty() {
let names: Vec<String> = done.iter().map(|m| m.to.display().to_string()).collect();
message.push_str(&format!(" with its sidecar {}", names.join(", ")));
}
let mut pending_sidecars = vec![m];
for m in remaining.filter(|m| m.needed(fs)) {
// The two case spellings can name one file. Keep one operation for
// those aliases, while retaining distinct sidecars on sensitive volumes.
let alias = pending_sidecars.iter().any(|p| {
folded_path(&p.from.to_string_lossy()) == folded_path(&m.from.to_string_lossy())
&& folded_path(&p.to.to_string_lossy()) == folded_path(&m.to.to_string_lossy())
&& fs.same_file(&p.from, &m.from)
});
if !alias {
pending_sidecars.push(m);
}
}
note_companions(&mut pending_sidecars, packets);
return Err(MoveError { message, moved: true, pending_sidecars });
}
let mut pending_sidecars = Vec::new();
for m in done.iter().rev() {
if m.copied {
// The original is still there: a leftover copy loses nothing.
if fs.remove_file(&m.to).is_ok() {
packets.retain(|p| p.path != m.to);
}
} else {
let aliases = packet_aliases(&packets, fs, &m.to);
if let Err(e) = move_one(fs, &m.to, &m.from) {
message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", m.from.display(), m.to.display()));
pending_sidecars.push(SidecarMove { from: m.to.clone(), to: m.from.clone(), copied: false, companions: Vec::new() });
} else {
advance_packets(&mut packets, &aliases, &m.from, false, fs);
}
}
}
note_companions(&mut pending_sidecars, packets);
return Err(MoveError { message, moved: false, pending_sidecars });
}
advance_packets(&mut packets, &aliases, &m.to, m.copied, fs);
done.push(m);
}
Ok(())
}
/// What a failed batch of moves ([`move_all`]) left behind.
#[derive(Debug)]
pub(crate) struct BatchMoveError {
/// The first failure and every move that could not be undone.
pub message: String,
/// Moves that stayed done, (from, to): those files are at `to` now.
pub stuck: Vec<(String, String)>,
/// Sidecars still separated from the path where their photo ended up, in either direction.
pub pending_sidecars: Vec<SidecarMove>,
}
/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone.
/// Moves that can't be undone (e.g. the network share went away) are reported, not ignored.
pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::result::Result<(), BatchMoveError> {
let mut done: Vec<&(String, String)> = Vec::new();
for m in moves {
let Err(e) = move_file_with(fs, &m.0, &m.1) else {
done.push(m);
continue;
};
let mut message = e.message;
let mut pending_sidecars = e.pending_sidecars;
let mut stuck = Vec::new();
if e.moved {
stuck.push(m.clone());
}
for d in done.iter().rev() {
if let Err(mut e) = move_file_with(fs, &d.1, &d.0) {
message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message));
if !e.moved {
stuck.push((*d).clone());
}
pending_sidecars.append(&mut e.pending_sidecars);
}
}
return Err(BatchMoveError { message, stuck, pending_sidecars });
}
Ok(())
}
/// Final `SetFile` per photo (nested batches included), filtered by destination only afterward.
pub(super) fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec<Op>) {
let mut pending = vec![op];
let mut order = Vec::new();
let mut final_ops = HashMap::new();
while let Some(op) = pending.pop() {
match op {
Op::Batch { ops } => pending.extend(ops.iter().rev()),
Op::SetFile { id, .. } if final_ops.insert(*id, op).is_none() => order.push(*id),
_ => {}
}
}
out.extend(order.into_iter().filter_map(|id| {
let op = final_ops.get(&id).copied()?;
matches!(op, Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str())).then(|| op.clone())
}));
}
+295
View File
@@ -0,0 +1,295 @@
use super::fs::{MoveFs, RealFs, folded_path};
use super::moves::{BatchMoveError, move_all, set_file_ops_to};
use super::sidecar::{PacketVersion, retry_sidecars, retry_sidecars_for_paths};
use super::tokens::{expand, split_ext};
use super::*;
/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies)
/// rename once. No file-system calls.
pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec<std::sync::Arc<Photo>> {
let mut seen_paths: HashSet<String> = HashSet::new();
ids.iter()
.filter_map(|id| cat.photo(*id))
.filter(|p| match &p.source {
Source::File { path } => seen_paths.insert(path.clone()),
_ => true,
})
.cloned()
.collect()
}
/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check
/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow
/// drive). Whether a case-only change names this very file is decided on the disk.
pub fn plan_rename_photos(photos: &[std::sync::Arc<Photo>], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec<RenamePlan> {
plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b))
}
/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether
/// two spellings name one file (see [`MoveFs`]).
fn plan_rename_core(
photos: &[std::sync::Arc<Photo>],
template: &str,
start: usize,
exists: &dyn Fn(&Path) -> bool,
same_file: &dyn Fn(&Path, &Path) -> bool,
) -> Vec<RenamePlan> {
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
let mut out = Vec::new();
for (i, p) in photos.iter().enumerate() {
let seq = start.saturating_add(i);
let want = expand(template, p, seq);
let (stem, ext) = split_ext(&want);
let (stem, ext) = (stem.to_string(), ext.to_string());
let candidate = |k: usize| {
if k == 0 {
want.clone()
} else if ext.is_empty() {
format!("{stem}-{k}")
} else {
format!("{stem}-{k}.{ext}")
}
};
let (to, to_path) = match &p.source {
Source::File { path } => {
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
let mut k = 0;
loop {
let name = candidate(k);
let tp = dir.join(&name).to_string_lossy().to_string();
let key = folded_path(&tp);
// this very file, maybe spelled differently (case-insensitive volume)? By
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
let (from, to) = (Path::new(path), Path::new(&tp));
let same = from == to || (key == folded_path(path) && exists(to) && same_file(from, to));
// free: not claimed in this batch and not on disk (unless it is this very file).
// A file this batch moves away still counts as taken: simple and safe.
if !taken.contains(&key) && (same || !exists(to)) {
taken.insert(key);
break (name, Some(tp));
}
k += 1;
}
}
Source::Demo { .. } => {
let mut k = 0;
loop {
let name = candidate(k);
if taken.insert(format!("demo:{}", name.to_lowercase())) {
break (name, None);
}
k += 1;
}
}
};
let from_path = match &p.source {
Source::File { path } => Some(path.clone()),
_ => None,
};
out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path });
}
out
}
impl Session {
/// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions.
pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
self.plan_rename_with(&RealFs, ids, template, start)
}
/// [`Session::plan_rename`] on the file system `fs`.
pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b))
}
/// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op
/// that updates the photos (and their virtual copies).
///
/// When a failure can't be fully rolled back, the files that kept their new names are
/// committed as a smaller undoable rename, so the library points at them (nothing goes
/// missing), and the error lists them (old → new).
pub fn apply_rename(&mut self, plans: &[RenamePlan]) -> Result<usize> {
self.apply_rename_with(&RealFs, plans)
}
/// Complete retained repairs before a file or folder change can stale their exact paths.
pub(crate) fn repair_sidecars_with(&mut self, fs: &dyn MoveFs) -> Result<()> {
for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) {
repair.validate_repair(fs).map_err(EngineError::Other)?;
}
retry_sidecars(fs, &mut self.pending_sidecar_repairs).map_err(EngineError::Other)?;
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
retry_sidecars(fs, &mut entry.retry_sidecars).map_err(EngineError::Other)?;
}
Ok(())
}
pub(crate) fn repair_sidecars_for_paths(&mut self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> {
self.validate_sidecar_packets_for_paths(paths, fs)?;
retry_sidecars_for_paths(fs, &mut self.pending_sidecar_repairs, paths).map_err(EngineError::Other)?;
for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
retry_sidecars_for_paths(fs, &mut entry.retry_sidecars, paths).map_err(EngineError::Other)?;
}
Ok(())
}
/// A healthy shared packet may be saved while another photo's repair is unavailable.
/// Check its retained content too, without forcing that unrelated physical operation.
pub(crate) fn validate_sidecar_packets_for_paths(&self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> {
for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) {
if paths.iter().any(|path| repair.touches(fs, path)) {
repair.validate_repair(fs).map_err(EngineError::Other)?;
} else {
for packet in &repair.companions {
if paths.iter().any(|path| fs.same_file(path, &packet.path)) {
packet.validate(fs).map_err(EngineError::Other)?;
}
}
}
}
Ok(())
}
/// Only a successful application write updates a retained content version. Atomic writes
/// replace inodes; aliases that now resolve to the written path observe the new content.
pub(crate) fn note_sidecar_write(&mut self, path: &Path, bytes: &[u8]) {
let version = PacketVersion::of(bytes);
for repair in self.pending_sidecar_repairs.iter_mut().chain(self.undo.iter_mut().chain(&mut self.redo).flat_map(|e| &mut e.retry_sidecars)) {
for packet in &mut repair.companions {
if RealFs.same_file(path, &packet.path) {
packet.version = Some(version);
}
}
}
}
/// [`Session::apply_rename`] on the file system `fs`.
pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result<usize> {
self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }))
}
/// Carry out `plans` (renames or moves to another folder): move the files with their sidecars
/// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the
/// photos (and their virtual copies) at the new paths. Undo moves the files back.
pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result<usize> {
// A partly completed rename can already have the requested photo name. Repair
// its sidecars before treating that empty move list as a successful retry.
self.repair_sidecars_with(fs)?;
let mut ops = Vec::new();
let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect();
for p in self.catalog.photos() {
let plan = match &p.source {
Source::File { path } => by_path.get(path.as_str()).copied(),
Source::Demo { .. } => plans.iter().find(|x| x.id == p.id.0 && x.from_path.is_none()),
};
let Some(plan) = plan else { continue };
let source = match &plan.to_path {
Some(t) if matches!(&p.source, Source::File { path } if Path::new(path) == Path::new(t)) => p.source.clone(),
Some(t) => Source::File { path: t.clone() },
None => p.source.clone(),
};
if plan.to != p.file_name || source != p.source {
ops.push(Op::SetFile { id: p.id, file_name: plan.to.clone(), source });
}
}
let n = ops.len();
let op = Op::Batch { ops };
let moves: Vec<(String, String)> = plans
.iter()
.filter_map(|pl| match (&pl.from_path, &pl.to_path) {
(Some(a), Some(b)) if Path::new(a) != Path::new(b) => Some((a.clone(), b.clone())),
_ => None,
})
.collect();
if let Err(e) = move_all(fs, &moves) {
return Err(EngineError::Other(self.follow_stuck(&op, e, true).0));
}
if n > 0
&& let Err(err) = self.commit(&label(n), op)
{
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
if let Err(e) = move_all(fs, &back) {
return Err(EngineError::Other(format!("{err}; the renamed files could not all be moved back: {}", e.message)));
}
return Err(err);
}
Ok(n)
}
/// After a failed batch of moves for `op`: point the photos whose files stayed moved at
/// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged
/// change), so the library matches the disk. Returns the message and, for a history retry,
/// the correction's inverse file ops (so a retry doesn't lose the original paths).
pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> (String, Vec<Op>) {
let mut msg = e.message;
let pending_sidecars = e.pending_sidecars;
if e.stuck.is_empty() && (!undoable || pending_sidecars.is_empty()) {
return (msg, Vec::new());
}
let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect();
let mut keep = Vec::new();
set_file_ops_to(op, &to, &mut keep);
let list: Vec<String> = e.stuck.iter().map(|(a, b)| format!("{a} → {b}")).collect();
let n = e.stuck.len();
let s = if n == 1 { "" } else { "s" };
let fix = Op::Batch { ops: keep };
let mut inverses = Vec::new();
let applied = if undoable {
let label = if n == 0 { "Repair Rename Sidecars".to_string() } else { format!("Rename {n} Photo{s} (partly)") };
self.commit(&label, fix).map(|()| {
if let Some(entry) = self.undo.last_mut() {
entry.retry_sidecars.extend(pending_sidecars);
}
})
} else {
match self.apply_catalog(fix.clone()) {
Ok(inv) => {
inverses = match inv {
Op::Batch { ops } => ops,
op => vec![op],
};
self.pending_log.push(fix);
Ok(())
}
Err(e) => Err(e),
}
};
match applied {
Ok(()) if n > 0 => msg
.push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))),
Ok(()) => {}
Err(err) => msg.push_str(&format!(
". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}",
list.join(", ")
)),
}
(msg, inverses)
}
/// Physical moves to the final `SetFile` destination of each photo. A merged history
/// batch can rename the same photo repeatedly; intermediate catalog paths need no file.
pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> {
let mut pending = vec![op];
let mut order = Vec::new();
let mut targets = HashMap::new();
while let Some(op) = pending.pop() {
match op {
Op::Batch { ops } => pending.extend(ops.iter().rev()),
Op::SetFile { id, source, .. } if targets.insert(*id, source).is_none() => {
order.push(*id);
}
_ => {}
}
}
// Virtual copies share one physical move, keeping the original batch order.
let mut seen = HashSet::new();
order
.into_iter()
.filter_map(|id| {
let Source::File { path: to } = targets.get(&id).copied()? else { return None };
let Source::File { path: from } = &self.catalog.photo(id)?.source else { return None };
(Path::new(from) != Path::new(to) && seen.insert(from)).then(|| (from.clone(), to.clone()))
})
.collect()
}
}
+174
View File
@@ -0,0 +1,174 @@
use super::fs::{MoveFs, is_respelling, move_one};
use super::*;
/// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may
/// already have followed its new path, so a retry cannot infer this work from the catalog.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct SidecarMove {
pub from: PathBuf,
pub to: PathBuf,
pub(super) copied: bool,
/// Observed packets in the partial move, including the separated source. Absent planned
/// destinations are never owned; a completed repair advances these observations.
pub(super) companions: Vec<RetainedPacket>,
}
/// A content version, not an assertion that a pathname still names the same packet.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct PacketVersion {
pub(super) len: u64,
pub(super) hash: lightcraft_preview::Hash128,
}
impl PacketVersion {
pub(super) fn of(bytes: &[u8]) -> Self {
Self { len: bytes.len() as u64, hash: lightcraft_preview::hash_bytes(bytes) }
}
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct RetainedPacket {
pub(super) path: PathBuf,
// An unreadable observation remains untrusted even if a later read succeeds.
pub(super) version: Option<PacketVersion>,
}
impl RetainedPacket {
pub(super) fn validate(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> {
if self.version.is_none() {
return Err(format!("can't verify retained XMP {}: its content was unreadable when the partial move was recorded", self.path.display()));
}
let current = fs.packet_version(&self.path).map_err(|e| format!("can't verify retained XMP {}: {e}", self.path.display()))?;
if self.version != Some(current) {
return Err(format!(
"retained XMP {} changed since the partial move; packet ownership must be resolved before retrying",
self.path.display()
));
}
Ok(())
}
}
/// Advance only packets whose operation actually succeeded. A copy retains its original;
/// a move retires vanished source aliases and adds the observed destination.
pub(super) fn advance_packets(packets: &mut Vec<RetainedPacket>, aliases: &[PathBuf], to: &Path, copied: bool, fs: &dyn MoveFs) {
let Some(mut packet) = packets.iter().find(|p| aliases.contains(&p.path)).cloned() else { return };
if !copied {
packets.retain(|p| !aliases.contains(&p.path) || fs.is_file(&p.path));
}
packet.path = to.to_path_buf();
if !packets.iter().any(|p| p.path == packet.path) {
packets.push(packet);
}
}
pub(super) fn packet_aliases(packets: &[RetainedPacket], fs: &dyn MoveFs, from: &Path) -> Vec<PathBuf> {
packets.iter().filter(|p| p.path == from || fs.same_file(&p.path, from)).map(|p| p.path.clone()).collect()
}
impl SidecarMove {
fn validate_packets(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> {
for packet in &self.companions {
packet.validate(fs)?;
}
Ok(())
}
/// Validate a queued retry and return the copy decision for this physical operation.
pub(super) fn validate_repair(&self, fs: &dyn MoveFs) -> std::result::Result<bool, String> {
self.validate_packets(fs)?;
let respelling = is_respelling(fs, &self.from, &self.to);
// Batch rollback may have restored another photo using this source packet.
let copied = self.copied || (!respelling && fs.stem_sibling(&self.from).is_some());
if fs.exists(&self.to) && (copied || !respelling) {
return Err(format!("{} already exists while repairing {} → {}", self.to.display(), self.from.display(), self.to.display()));
}
Ok(copied)
}
pub(super) fn needed(&self, fs: &dyn MoveFs) -> bool {
fs.is_file(&self.from) && !(fs.exists(&self.to) && (self.copied || !is_respelling(fs, &self.from, &self.to)))
}
pub(super) fn touches(&self, fs: &dyn MoveFs, path: &Path) -> bool {
let same_path = |endpoint: &Path| {
endpoint == path
|| fs.same_file(endpoint, path)
|| (endpoint.file_name() == path.file_name()
&& endpoint.parent().zip(path.parent()).is_some_and(|(a, b)| a == b || fs.same_file(a, b)))
};
same_path(&self.from) || same_path(&self.to)
}
pub(super) fn apply(&self, fs: &dyn MoveFs) -> std::io::Result<()> {
if self.copied { fs.copy_no_replace(&self.from, &self.to) } else { move_one(fs, &self.from, &self.to) }
}
}
/// Complete known unfinished sidecars before moving any more photos. Completed operations
/// leave the queue; a failure retains that operation and the rest, without replacing a file.
pub(crate) fn retry_sidecars(fs: &dyn MoveFs, pending: &mut Vec<SidecarMove>) -> std::result::Result<(), String> {
validate_sidecars(fs, pending)?;
while !pending.is_empty() {
apply_repair(fs, pending, 0)?;
}
Ok(())
}
/// Preflight a detached history entry before global queues are allowed to retire. The
/// caller can validate without mutating it; retry revalidates before each physical operation.
pub(crate) fn validate_sidecars(fs: &dyn MoveFs, pending: &[SidecarMove]) -> std::result::Result<(), String> {
for repair in pending {
repair.validate_repair(fs)?;
}
Ok(())
}
/// Finish one operation and transfer its observed content to the remaining repairs. Do not
/// infer ownership by reading an occupied destination or by retaining a planned pathname.
fn apply_repair(fs: &dyn MoveFs, pending: &mut Vec<SidecarMove>, i: usize) -> std::result::Result<(), String> {
let Some(mut repair) = pending.get(i).cloned() else { return Ok(()) };
repair.copied = repair.validate_repair(fs)?;
let aliases = packet_aliases(&repair.companions, fs, &repair.from);
repair.apply(fs).map_err(|e| format!("rename {} → {}: {e}", repair.from.display(), repair.to.display()))?;
for remaining in pending.iter_mut() {
advance_packets(&mut remaining.companions, &aliases, &repair.to, repair.copied, fs);
}
pending.remove(i);
Ok(())
}
/// Repair only operations that affect a sidecar being saved. Unrelated unavailable originals
/// must not block healthy metadata saves. Completed repairs retire; failures stay queued.
pub(crate) fn retry_sidecars_for_paths(fs: &dyn MoveFs, pending: &mut Vec<SidecarMove>, paths: &[PathBuf]) -> std::result::Result<(), String> {
for repair in pending.iter().filter(|m| paths.iter().any(|path| m.touches(fs, path))) {
repair.validate_repair(fs)?;
}
let mut i = 0;
while let Some(m) = pending.get(i) {
if paths.iter().any(|path| m.touches(fs, path)) {
// Path/alias equality only locates an observation; validation above proves its
// content. A newly occupied, previously absent peer destination is a collision.
if let Some(path) = paths.iter().find(|path| {
fs.exists(path)
&& !fs.same_file(path, &m.from)
&& !fs.same_file(path, &m.to)
&& !m.companions.iter().any(|known| fs.same_file(path, &known.path))
}) {
return Err(format!("{} already exists while repairing {} → {}", path.display(), m.from.display(), m.to.display()));
}
apply_repair(fs, pending, i)?;
} else {
i += 1;
}
}
Ok(())
}
/// Every record came from an actual packet observed before this move. Successful physical
/// operations have already advanced its path; missing planned destinations are not recorded.
pub(super) fn note_companions(pending: &mut [SidecarMove], packets: Vec<RetainedPacket>) {
for repair in pending {
repair.companions.clone_from(&packets);
}
}
+271
View File
@@ -0,0 +1,271 @@
use super::*;
pub(super) fn split_ext(name: &str) -> (&str, &str) {
match name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => (stem, ext),
_ => (name, ""),
}
}
fn sanitize(s: &str) -> String {
let t: String =
s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() { '-' } else { c }).collect();
t.trim().trim_matches('.').trim().to_string()
}
/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name
/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month
/// that doesn't parse gives nothing.
pub(super) fn format_date(iso: &str, fmt: &str) -> String {
let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string();
let month = iso.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or("");
let mut out = String::new();
let mut it = fmt.chars();
while let Some(c) = it.next() {
if c != '%' {
out.push(c);
continue;
}
match it.next() {
Some('Y') => out.push_str(&part(0, 4)),
Some('y') => out.push_str(&part(2, 4)),
Some('m') => out.push_str(&part(5, 7)),
Some('B') => out.push_str(month),
Some('b') => out.push_str(month.get(..3).unwrap_or(month)),
Some('d') => out.push_str(&part(8, 10)),
Some('H') => out.push_str(&part(11, 13)),
Some('M') => out.push_str(&part(14, 16)),
Some('S') => out.push_str(&part(17, 19)),
Some('%') => out.push('%'),
Some(o) => {
out.push('%');
out.push(o);
}
None => out.push('%'),
}
}
out
}
/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and
/// command description shows; a test checks that [`expand_tokens`] knows each of them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct TokenHelp {
/// The token as typed, e.g. `{seq:3}`.
pub tag: &'static str,
/// Other spellings with the same meaning.
pub aliases: &'static [&'static str],
pub meaning: &'static str,
}
/// Every template token, in the order the help shows them.
pub const TOKENS: &[TokenHelp] = &[
TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" },
TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" },
TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" },
TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" },
TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" },
TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" },
TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" },
TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" },
TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" },
TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" },
TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" },
TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" },
TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" },
TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" },
];
/// The `%` directives of `{date:…}`.
pub const DATE_DIRECTIVES: &[(&str, &str)] = &[
("%Y", "year, 4 digits"),
("%y", "year, 2 digits"),
("%m", "month 01–12"),
("%B", "month name (January)"),
("%b", "short month name (Jan)"),
("%d", "day 01–31"),
("%H", "hour 00–23"),
("%M", "minute"),
("%S", "second"),
("%%", "a literal %"),
];
/// How templates behave, one sentence each (shown under the token list).
pub const TEMPLATE_NOTES: &[&str] = &[
"The original extension is always added; {ext} only puts it inside the name as well.",
"A blank template keeps the original names.",
"{date} is the capture time; a photo without one uses the time it was imported.",
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.",
"Unknown tags stay as typed — check the preview for a {typo}.",
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….",
];
/// The tokens as one line (`{name} {num} {seq} …`), for compact hints.
pub fn token_summary() -> String {
TOKENS.iter().map(|t| t.tag).collect::<Vec<_>>().join(" ")
}
/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48).
pub fn sample_photo() -> Photo {
let mut p =
Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00");
p.captured = Some("2026-01-14T05:58:48".into());
p.rating = 4;
p.meta.camera = "Canon EOS R5".into();
p.meta.lens = "RF24-70mm F2.8".into();
p.meta.iso = Some(400);
p.meta.title = "Harbour".into();
p.meta.creator = "Ann Lee".into();
p
}
/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column.
pub fn token_example(tag: &str) -> String {
expand_tokens(tag, &sample_photo(), 1, 1)
}
/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be
/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels.
pub fn folder_template_error(template: &str) -> Option<String> {
let t = template.trim();
if t.is_empty() {
return Some("the folder template is empty".into());
}
let b = t.as_bytes();
if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') {
return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into());
}
if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) {
return Some("the folder template may not contain . or .. folders".into());
}
None
}
/// The folders (relative to the destination) a folder template gives `p`: the template's own `/`
/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder
/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and
/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty
/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s
/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped.
pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec<String> {
template
.split(['/', '\\'])
.map(str::trim)
.filter(|s| !s.is_empty() && !matches!(*s, "." | ".."))
.map(|s| {
let v = sanitize(&expand_tokens(s, p, seq, 1));
if v.is_empty() { "unknown".to_string() } else { v }
})
.collect()
}
/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning.
pub fn unknown_tokens(template: &str) -> Vec<String> {
let p = sample_photo();
let mut out = Vec::new();
let mut rest = template;
while let Some(i) = rest.find('{') {
let Some(j) = rest[i..].find('}') else { break };
let tag = &rest[i..i + j + 1];
if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) {
out.push(tag.to_string());
}
rest = &rest[i + j + 1..];
}
out
}
/// Every token with its meaning and example, the date directives and the notes, as JSON (the
/// `photo.renameTokens` command).
pub fn token_help_json() -> serde_json::Value {
let tokens: Vec<serde_json::Value> = TOKENS
.iter()
.map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)}))
.collect();
let directives: Vec<serde_json::Value> = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect();
serde_json::json!({
"tokens": tokens,
"dateDirectives": directives,
"notes": TEMPLATE_NOTES,
"sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48",
})
}
/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension.
pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
let (stem, ext) = split_ext(&p.file_name);
let out = expand_tokens(template, p, seq, 1);
let mut stem_out = sanitize(&out);
if stem_out.is_empty() {
stem_out = sanitize(stem);
}
if stem_out.is_empty() {
stem_out = "photo".into();
}
if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") }
}
/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}`
/// is zero-padded to `seq_width` digits.
pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String {
expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None)
}
/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text
/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename
/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way.
pub fn expand_tokens_with(
template: &str,
p: &Photo,
seq: usize,
seq_width: usize,
extra: &dyn Fn(&str, Option<&str>, &str) -> Option<String>,
) -> String {
let (stem, ext) = split_ext(&p.file_name);
let mut out = String::new();
let mut rest = template;
while let Some(i) = rest.find('{') {
out.push_str(&rest[..i]);
let Some(j) = rest[i..].find('}') else {
out.push_str(&rest[i..]);
rest = "";
break;
};
let tok = &rest[i + 1..i + j];
let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None));
let lower = name.trim().to_ascii_lowercase();
let v = match extra(&lower, arg, tok) {
Some(v) => v,
None => match lower.as_str() {
"name" | "filename" => stem.to_string(),
"num" => {
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
stem[stem.len() - digits..].to_string()
}
"seq" | "n" => {
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9);
format!("{seq:0w$}")
}
"date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")),
"folder" => match &p.source {
Source::File { path } => {
Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()
}
Source::Demo { .. } => String::new(),
},
"camera" => p.meta.camera.clone(),
"lens" => p.meta.lens.clone(),
"iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(),
"rating" => p.rating.to_string(),
"title" => p.meta.title.clone(),
"creator" => p.meta.creator.clone(),
"ext" => ext.to_string(),
_ => format!("{{{tok}}}"),
},
};
out.push_str(&v);
rest = &rest[i + j + 1..];
}
out.push_str(rest);
out
}