mirror of
https://github.com/sxyazi/yazi.git
synced 2026-07-25 08:41:05 +00:00
Add counters to bulk rename function
This commit is contained in:
parent
e9742cb809
commit
f64c0d9694
14 changed files with 2291 additions and 19 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4595,6 +4595,7 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
"yazi-binding",
|
"yazi-binding",
|
||||||
"yazi-boot",
|
"yazi-boot",
|
||||||
"yazi-config",
|
"yazi-config",
|
||||||
|
|
|
||||||
|
|
@ -30,16 +30,17 @@ yazi-watcher = { path = "../yazi-watcher", version = "25.9.15" }
|
||||||
yazi-widgets = { path = "../yazi-widgets", version = "25.9.15" }
|
yazi-widgets = { path = "../yazi-widgets", version = "25.9.15" }
|
||||||
|
|
||||||
# External dependencies
|
# External dependencies
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
crossterm = { workspace = true }
|
crossterm = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
hashbrown = { workspace = true }
|
hashbrown = { workspace = true }
|
||||||
mlua = { workspace = true }
|
mlua = { workspace = true }
|
||||||
paste = { workspace = true }
|
paste = { workspace = true }
|
||||||
scopeguard = { workspace = true }
|
scopeguard = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tokio-stream = { workspace = true }
|
tokio-stream = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
unicode-width = { workspace = true }
|
||||||
|
|
||||||
[target."cfg(unix)".dependencies]
|
[target."cfg(unix)".dependencies]
|
||||||
libc = { workspace = true }
|
libc = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ use crate::{Actor, Ctx};
|
||||||
|
|
||||||
pub struct BulkRename;
|
pub struct BulkRename;
|
||||||
|
|
||||||
|
mod counters;
|
||||||
|
mod filename_template;
|
||||||
|
mod name_generator;
|
||||||
|
|
||||||
|
use name_generator::generate_names;
|
||||||
|
|
||||||
impl Actor for BulkRename {
|
impl Actor for BulkRename {
|
||||||
type Options = VoidOpt;
|
type Options = VoidOpt;
|
||||||
|
|
||||||
|
|
@ -60,15 +66,8 @@ impl Actor for BulkRename {
|
||||||
defer!(AppProxy::resume());
|
defer!(AppProxy::resume());
|
||||||
AppProxy::stop().await;
|
AppProxy::stop().await;
|
||||||
|
|
||||||
let new: Vec<_> = Local
|
let new_names = Local.read_to_string(&tmp).await?;
|
||||||
.read_to_string(&tmp)
|
let new = Self::parse_new_names(&new_names, old.len()).await?;
|
||||||
.await?
|
|
||||||
.lines()
|
|
||||||
.take(old.len())
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, s)| Tuple::new(i, s))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Self::r#do(root, old, new, selected).await
|
Self::r#do(root, old, new, selected).await
|
||||||
});
|
});
|
||||||
succ!();
|
succ!();
|
||||||
|
|
@ -76,6 +75,29 @@ impl Actor for BulkRename {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BulkRename {
|
impl BulkRename {
|
||||||
|
/// Reads a number of lines from a string, attempting to parse them as either
|
||||||
|
/// fixed filenames or counter-based templates.
|
||||||
|
///
|
||||||
|
/// The number of expected lines should match `expected_count`.
|
||||||
|
/// If parsing fails, displays all errors to the user and waits for ENTER
|
||||||
|
/// before returning an error.
|
||||||
|
async fn parse_new_names(new_names: &str, expected_count: usize) -> Result<Vec<Tuple>> {
|
||||||
|
match generate_names(&mut new_names.lines().take(expected_count)) {
|
||||||
|
Ok(paths) => Ok(paths),
|
||||||
|
Err(errors) => {
|
||||||
|
// Show all parse errors in TTY, then return an error
|
||||||
|
terminal_clear(TTY.writer())?;
|
||||||
|
let err = format! {"Found errors in the filenames:\n\n{errors}\nPress ENTER to exit"};
|
||||||
|
execute!(TTY.writer(), Print(err),)?;
|
||||||
|
// Wait for user input
|
||||||
|
TTY.reader().read_exact(&mut [0])?;
|
||||||
|
|
||||||
|
// Return an error to skip further rename
|
||||||
|
Err(anyhow::anyhow!("Parsing errors in rename lines"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn r#do(
|
async fn r#do(
|
||||||
root: usize,
|
root: usize,
|
||||||
old: Vec<Tuple>,
|
old: Vec<Tuple>,
|
||||||
|
|
|
||||||
64
yazi-actor/src/mgr/bulk_rename/counters/ansi.rs
Normal file
64
yazi-actor/src/mgr/bulk_rename/counters/ansi.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
//! This module provides functionality for managing ANSI letter counters for both
|
||||||
|
//! uppercase and lowercase letters, following Excel's alphabetic counter style.
|
||||||
|
|
||||||
|
use super::{CounterFormatter, LOWERCASE, UPPERCASE, write_number_as_letters_gen};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// A helper structure for generating uppercase ANSI letters (e.g., A, B, ..., AA, AB).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct AnsiUpper;
|
||||||
|
|
||||||
|
/// A helper structure for generating lowercase ANSI letters (e.g., a, b, ..., aa, ab).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct AnsiLower;
|
||||||
|
|
||||||
|
impl_counter_formatter! { AnsiUpper, UPPERCASE }
|
||||||
|
impl_counter_formatter! { AnsiLower, LOWERCASE }
|
||||||
|
|
||||||
|
/// Converts ANSI letters (e.g., "A", "Z", "AA") to their corresponding numeric values.
|
||||||
|
/// The conversion follows Excel's alphabetic counter rules: 'A' = 1, 'B' = 2, ...,
|
||||||
|
/// 'Z' = 26, 'AA' = 27, etc.
|
||||||
|
///
|
||||||
|
/// The `UPPERCASE` constant determines whether the string should be validated
|
||||||
|
/// as uppercase or lowercase.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Returns `Some(u32)` if conversion is successful; otherwise, returns `None`.
|
||||||
|
#[inline]
|
||||||
|
fn convert_letters_to_number<const UPPERCASE: bool>(value: &str) -> Option<u32> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if UPPERCASE {
|
||||||
|
if !value.chars().all(|c| c.is_ascii_uppercase()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
} else if !value.chars().all(|c| c.is_ascii_lowercase()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = value.chars().rev().enumerate().fold(0_u32, |acc, (i, c)| {
|
||||||
|
acc + ((c as u32) - (if UPPERCASE { 'A' } else { 'a' } as u32) + 1) * 26_u32.pow(i as u32)
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the numeric value as ANSI letters (e.g., 1 → "A", 27 → "AA") into the provided buffer.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `num` - The numeric value to convert.
|
||||||
|
/// * `width` - The minimum width of the generated string, padded with zeros if necessary.
|
||||||
|
/// * `buf` - The buffer to write the resulting string into.
|
||||||
|
#[inline]
|
||||||
|
fn write_number_as_letters<const UPPERCASE: bool>(
|
||||||
|
num: u32,
|
||||||
|
width: usize,
|
||||||
|
buf: &mut impl fmt::Write,
|
||||||
|
) -> fmt::Result {
|
||||||
|
let base = if UPPERCASE { b'A' } else { b'a' };
|
||||||
|
write_number_as_letters_gen(num, width, 26, |r| (base + r as u8) as char, buf)
|
||||||
|
}
|
||||||
104
yazi-actor/src/mgr/bulk_rename/counters/cyrillic.rs
Normal file
104
yazi-actor/src/mgr/bulk_rename/counters/cyrillic.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
//! This module provides functionality for managing Cyrillic letter counters for both
|
||||||
|
//! uppercase and lowercase letters, following Excel's alphabetic counter style.
|
||||||
|
|
||||||
|
use super::{CounterFormatter, LOWERCASE, UPPERCASE, write_number_as_letters_gen};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// An array of uppercase Cyrillic letters used for indexing and mapping.
|
||||||
|
/// This array includes all uppercase Cyrillic letters excluding 'Ё', 'Й', 'Ъ', 'Ы', 'Ь'.
|
||||||
|
const UPPERCASE_CYRILLIC: [char; 28] = [
|
||||||
|
'А', 'Б', 'В', 'Г', 'Д', 'Е', 'Ж', 'З', 'И', 'К', 'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У',
|
||||||
|
'Ф', 'Х', 'Ц', 'Ч', 'Ш', 'Щ', 'Э', 'Ю', 'Я',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// An array of lowercase Cyrillic letters used for indexing and mapping.
|
||||||
|
/// This array includes all lowercase Cyrillic letters excluding 'ё', 'й', 'ъ', 'ы', 'ь'.
|
||||||
|
const LOWERCASE_CYRILLIC: [char; 28] = [
|
||||||
|
'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у',
|
||||||
|
'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'э', 'ю', 'я',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A helper structure for generating uppercase Cyrillic letters (e.g., А, Б, В, ..., АА, АБ),
|
||||||
|
/// while excluding 'Ё', 'Й', 'Ъ', 'Ы' and 'Ь'.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct CyrillicUpper;
|
||||||
|
|
||||||
|
/// A helper structure for generating lowercase Cyrillic letters (e.g., а, б, в, ..., аа, аб),
|
||||||
|
/// while excluding 'ё', 'й', 'ъ', 'ы' and 'ь'.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct CyrillicLower;
|
||||||
|
|
||||||
|
impl_counter_formatter! { CyrillicUpper, UPPERCASE }
|
||||||
|
impl_counter_formatter! { CyrillicLower, LOWERCASE }
|
||||||
|
|
||||||
|
/// Converts Cyrillic letters (e.g., "Б", "В", "БА") to their corresponding numeric values.
|
||||||
|
/// The conversion follows Excel's alphabetic counter rules: 'А' = 1, 'Б' = 2, ...,
|
||||||
|
/// 'Я' = 28, 'АА' = 29, etc.
|
||||||
|
///
|
||||||
|
/// The `UPPERCASE` constant determines whether the string should be validated
|
||||||
|
/// as uppercase or lowercase.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Returns `Some(u32)` if conversion is successful; otherwise, returns `None`.
|
||||||
|
#[inline]
|
||||||
|
fn convert_letters_to_number<const UPPERCASE: bool>(value: &str) -> Option<u32> {
|
||||||
|
if invalid_string::<UPPERCASE>(value) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lookup = if UPPERCASE { &UPPERCASE_CYRILLIC } else { &LOWERCASE_CYRILLIC };
|
||||||
|
|
||||||
|
let result = value.chars().rev().enumerate().fold(0_u32, |acc, (i, c)| {
|
||||||
|
if let Some(index) = lookup.iter().position(|&x| x == c) {
|
||||||
|
acc + (index as u32 + 1) * 28_u32.pow(i as u32)
|
||||||
|
} else {
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the numeric value as Cyrillic letters (e.g., 1 → "А", 28 → "Я") into the provided buffer.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `num` - The numeric value to convert.
|
||||||
|
/// * `width` - The minimum width of the generated string, padded with zeros if necessary.
|
||||||
|
/// * `buf` - The buffer to write the resulting string into.
|
||||||
|
#[inline]
|
||||||
|
fn write_number_as_letters<const UPPERCASE: bool>(
|
||||||
|
num: u32,
|
||||||
|
width: usize,
|
||||||
|
buf: &mut impl fmt::Write,
|
||||||
|
) -> fmt::Result {
|
||||||
|
let lookup = if UPPERCASE { &UPPERCASE_CYRILLIC } else { &LOWERCASE_CYRILLIC };
|
||||||
|
|
||||||
|
write_number_as_letters_gen(num, width, 28, |remainder| lookup[remainder as usize], buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a string is non-empty and consists only of valid uppercase or
|
||||||
|
/// lowercase Cyrillic letters, excluding 'Ё', 'Й', 'Ъ', 'Ы', and 'Ь'
|
||||||
|
/// ('ё', 'й', 'ъ', 'ы' and 'ь').
|
||||||
|
///
|
||||||
|
/// The `UPPERCASE` constant determines whether to check uppercase or lowercase letters.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Returns `true` if the string is invalid; otherwise, returns `false`.
|
||||||
|
#[inline]
|
||||||
|
fn invalid_string<const UPPERCASE: bool>(str: &str) -> bool {
|
||||||
|
if str.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if UPPERCASE {
|
||||||
|
!str.chars().all(|c| {
|
||||||
|
// ('А'..='Я') == ('\u{0410}'..='\u{042F}')
|
||||||
|
('\u{0410}'..='\u{042F}').contains(&c) && !matches!(c, 'Ё' | 'Й' | 'Ъ' | 'Ы' | 'Ь')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
!str.chars().all(|c| {
|
||||||
|
// ('а'..='я') == ('\u{0430}'..='\u{044F}')
|
||||||
|
('\u{0430}'..='\u{044F}').contains(&c) && !matches!(c, 'ё' | 'й' | 'ъ' | 'ы' | 'ь')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
33
yazi-actor/src/mgr/bulk_rename/counters/digit.rs
Normal file
33
yazi-actor/src/mgr/bulk_rename/counters/digit.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
//! This module provides functionality for managing Arabic numeral counters.
|
||||||
|
|
||||||
|
use super::CounterFormatter;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// A helper structure for generating numeric values (e.g., 1, 2, ..., 999 or 001, 002).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Digits;
|
||||||
|
|
||||||
|
impl CounterFormatter for Digits {
|
||||||
|
/// Formats a value as a zero-padded string and writes it to a buffer.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `value` - The numeric value to format.
|
||||||
|
/// * `width` - The minimum width of the output string.
|
||||||
|
/// * `buf` - A mutable reference to a buffer.
|
||||||
|
#[inline]
|
||||||
|
fn value_to_buffer(
|
||||||
|
self,
|
||||||
|
value: u32,
|
||||||
|
width: usize,
|
||||||
|
buf: &mut impl fmt::Write,
|
||||||
|
) -> Result<(), fmt::Error> {
|
||||||
|
write!(buf, "{value:0>width$}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a zero-padded numeric string into a `u32` value.
|
||||||
|
#[inline]
|
||||||
|
fn string_to_value(self, value: &str) -> Option<u32> {
|
||||||
|
value.parse().ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
99
yazi-actor/src/mgr/bulk_rename/counters/geneal.rs
Normal file
99
yazi-actor/src/mgr/bulk_rename/counters/geneal.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// This macro generates an implementation of CounterFormatter for a given
|
||||||
|
/// counter helper type.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `$type` - The target helper struct (e.g., `AnsiUpper`).
|
||||||
|
/// * `$case` - A boolean constant determining whether the counter uppercase or lowercase.
|
||||||
|
macro_rules! impl_counter_formatter {
|
||||||
|
($type:ty, $case:expr) => {
|
||||||
|
impl CounterFormatter for $type {
|
||||||
|
#[inline]
|
||||||
|
fn value_to_buffer(self, value: u32, width: usize, buf: &mut impl fmt::Write) -> fmt::Result {
|
||||||
|
write_number_as_letters::<{ $case }>(value, width, buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn string_to_value(self, value: &str) -> Option<u32> {
|
||||||
|
convert_letters_to_number::<{ $case }>(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a given numeric value into an alphabetic representation following a base-N numbering system,
|
||||||
|
/// similar to Excel-style column labels (e.g., 1 → A, 2 → B, ..., 26 → Z, 27 → AA, etc.).
|
||||||
|
///
|
||||||
|
/// This function generalizes the process for different alphabets by allowing a customizable base (`alphabet_len`)
|
||||||
|
/// and a transformation function (`convert_fn`) that maps remainder values to characters.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `num` - The numeric value to be converted. Since alphabetic numbering systems start from 1
|
||||||
|
/// (e.g., A = 1, B = 2), it should be non-zero value.
|
||||||
|
///
|
||||||
|
/// * `width` - The minimum width of the output string. If necessary, the result will be left-padded with '0'.
|
||||||
|
///
|
||||||
|
/// * `alphabet_len` - The base of the numbering system (e.g., 26 for Latin, 28 for Cyrillic, etc.).
|
||||||
|
///
|
||||||
|
/// * `convert_fn` - A closure that converts a remainder (`u32`) into a corresponding character.
|
||||||
|
/// - The `remainder` represents the remainder of division by `alphabet_len` (i.e., `num % alphabet_len`).
|
||||||
|
/// - The closure should map this remainder to a specific character in the corresponding alphabet
|
||||||
|
/// (e.g., `b'A' + remainder as u8`).
|
||||||
|
///
|
||||||
|
/// * `buf` - A mutable reference to a `fmt::Write` buffer where the result is written.
|
||||||
|
#[inline]
|
||||||
|
pub(super) fn write_number_as_letters_gen(
|
||||||
|
mut num: u32,
|
||||||
|
width: usize,
|
||||||
|
alphabet_len: u32,
|
||||||
|
mut convert_fn: impl FnMut(u32) -> char,
|
||||||
|
buf: &mut impl fmt::Write,
|
||||||
|
) -> fmt::Result {
|
||||||
|
if num == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stack_buf = ['0'; 10];
|
||||||
|
let mut written_len = 0;
|
||||||
|
|
||||||
|
for char in &mut stack_buf {
|
||||||
|
if num == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let remainder = (num - 1) % alphabet_len;
|
||||||
|
*char = convert_fn(remainder);
|
||||||
|
num = (num - remainder - 1) / alphabet_len;
|
||||||
|
written_len += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if num > 0 {
|
||||||
|
let mut vec_buf = Vec::with_capacity(20);
|
||||||
|
vec_buf.extend_from_slice(&stack_buf);
|
||||||
|
|
||||||
|
while num > 0 {
|
||||||
|
let remainder = (num - 1) % alphabet_len;
|
||||||
|
vec_buf.push(convert_fn(remainder));
|
||||||
|
num = (num - remainder - 1) / alphabet_len;
|
||||||
|
written_len += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in vec_buf.len()..width {
|
||||||
|
buf.write_char('0')?;
|
||||||
|
}
|
||||||
|
for &c in vec_buf.iter().rev() {
|
||||||
|
buf.write_char(c)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _ in written_len..width {
|
||||||
|
buf.write_char('0')?;
|
||||||
|
}
|
||||||
|
for &c in stack_buf[..written_len].iter().rev() {
|
||||||
|
buf.write_char(c)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
218
yazi-actor/src/mgr/bulk_rename/counters/mod.rs
Normal file
218
yazi-actor/src/mgr/bulk_rename/counters/mod.rs
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
//! This module provides functionality for creating and managing various formats
|
||||||
|
//! of counters.
|
||||||
|
//!
|
||||||
|
//! Counters are used to generate sequences of values based on different
|
||||||
|
//! alphabets and numeral systems, including ANSI, Cyrillic, and Roman letters,
|
||||||
|
//! as well as digits.
|
||||||
|
//!
|
||||||
|
//! # Overview
|
||||||
|
//!
|
||||||
|
//! The module defines traits and structures for different formats of counters,
|
||||||
|
//! including:
|
||||||
|
//!
|
||||||
|
//! - uppercase and lowercase ANSI letters;
|
||||||
|
//! - uppercase and lowercase Cyrillic letters;
|
||||||
|
//! - numeric counter;
|
||||||
|
//! - uppercase and lowercase Roman numerals.
|
||||||
|
//!
|
||||||
|
//! The `CharacterCounter` structure provides a unified interface for handling
|
||||||
|
//! these different formats of counters.
|
||||||
|
|
||||||
|
use super::filename_template::CounterBuilder;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test;
|
||||||
|
|
||||||
|
#[macro_use]
|
||||||
|
mod geneal;
|
||||||
|
mod ansi;
|
||||||
|
mod cyrillic;
|
||||||
|
mod digit;
|
||||||
|
mod roman;
|
||||||
|
|
||||||
|
const UPPERCASE: bool = true;
|
||||||
|
const LOWERCASE: bool = false;
|
||||||
|
|
||||||
|
pub use ansi::{AnsiLower, AnsiUpper};
|
||||||
|
pub use cyrillic::{CyrillicLower, CyrillicUpper};
|
||||||
|
pub use digit::Digits;
|
||||||
|
use geneal::write_number_as_letters_gen;
|
||||||
|
pub use roman::{RomanLower, RomanUpper};
|
||||||
|
|
||||||
|
/// Defines common behavior for counters that generate sequential values.
|
||||||
|
pub trait Counter {
|
||||||
|
/// Writes the current value to the provided buffer.
|
||||||
|
fn write_value(&self, buf: &mut impl fmt::Write) -> fmt::Result;
|
||||||
|
|
||||||
|
/// Advances the counter to the next value in the sequence.
|
||||||
|
fn advance(&mut self);
|
||||||
|
|
||||||
|
/// Resets the counter to its initial value.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn restart(&mut self);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait CounterFormatter: Copy {
|
||||||
|
/// Formats a value as a zero-padded string and writes it to a buffer.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `value` - The numeric value to format.
|
||||||
|
/// * `width` - The minimum width of the output string.
|
||||||
|
/// * `buf` - A mutable reference to a buffer.
|
||||||
|
fn value_to_buffer(
|
||||||
|
self,
|
||||||
|
value: u32,
|
||||||
|
width: usize,
|
||||||
|
buf: &mut impl fmt::Write,
|
||||||
|
) -> Result<(), fmt::Error>;
|
||||||
|
|
||||||
|
/// Parses a zero-padded numeric string into a `u32` value.
|
||||||
|
fn string_to_value(self, value: &str) -> Option<u32>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enum representing different formats of character-based counters.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CounterFormat {
|
||||||
|
/// Numeric values (1, 2, ..., 999).
|
||||||
|
Digits(Digits),
|
||||||
|
|
||||||
|
/// Uppercase ANSI letters (A, B, C, ..., AA, AB, ...).
|
||||||
|
AnsiUpper(AnsiUpper),
|
||||||
|
|
||||||
|
/// Lowercase ANSI letters (a, b, c, ..., aa, ab, ...).
|
||||||
|
AnsiLower(AnsiLower),
|
||||||
|
|
||||||
|
/// Uppercase Roman numerals (I, II, III, IV, V, ...).
|
||||||
|
RomanUpper(RomanUpper),
|
||||||
|
|
||||||
|
/// Lowercase Roman numerals (i, ii, iii, iv, v, ...).
|
||||||
|
RomanLower(RomanLower),
|
||||||
|
|
||||||
|
/// Uppercase Cyrillic letters (А, Б, В, ..., АА, АБ, ...).
|
||||||
|
CyrillicUpper(CyrillicUpper),
|
||||||
|
|
||||||
|
/// Lowercase Cyrillic letters (а, б, в, ..., аа, аб, ...).
|
||||||
|
CyrillicLower(CyrillicLower),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CounterFormat {
|
||||||
|
fn default() -> Self {
|
||||||
|
CounterFormat::Digits(Digits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CounterFormatter for CounterFormat {
|
||||||
|
fn value_to_buffer(
|
||||||
|
self,
|
||||||
|
value: u32,
|
||||||
|
width: usize,
|
||||||
|
buf: &mut impl fmt::Write,
|
||||||
|
) -> Result<(), fmt::Error> {
|
||||||
|
match self {
|
||||||
|
CounterFormat::Digits(fmt) => fmt.value_to_buffer(value, width, buf),
|
||||||
|
CounterFormat::AnsiUpper(fmt) => fmt.value_to_buffer(value, width, buf),
|
||||||
|
CounterFormat::AnsiLower(fmt) => fmt.value_to_buffer(value, width, buf),
|
||||||
|
CounterFormat::RomanUpper(fmt) => fmt.value_to_buffer(value, width, buf),
|
||||||
|
CounterFormat::RomanLower(fmt) => fmt.value_to_buffer(value, width, buf),
|
||||||
|
CounterFormat::CyrillicUpper(fmt) => fmt.value_to_buffer(value, width, buf),
|
||||||
|
CounterFormat::CyrillicLower(fmt) => fmt.value_to_buffer(value, width, buf),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_to_value(self, value: &str) -> Option<u32> {
|
||||||
|
match self {
|
||||||
|
CounterFormat::Digits(fmt) => fmt.string_to_value(value),
|
||||||
|
CounterFormat::AnsiUpper(fmt) => fmt.string_to_value(value),
|
||||||
|
CounterFormat::AnsiLower(fmt) => fmt.string_to_value(value),
|
||||||
|
CounterFormat::RomanUpper(fmt) => fmt.string_to_value(value),
|
||||||
|
CounterFormat::RomanLower(fmt) => fmt.string_to_value(value),
|
||||||
|
CounterFormat::CyrillicUpper(fmt) => fmt.string_to_value(value),
|
||||||
|
CounterFormat::CyrillicLower(fmt) => fmt.string_to_value(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CounterFormat {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
CounterFormat::Digits(_) => write!(f, "Numeric Digits"),
|
||||||
|
CounterFormat::AnsiUpper(_) => write!(f, "ANSI Uppercase Letters"),
|
||||||
|
CounterFormat::AnsiLower(_) => write!(f, "ANSI Lowercase Letters"),
|
||||||
|
CounterFormat::RomanUpper(_) => write!(f, "Roman Uppercase Numerals"),
|
||||||
|
CounterFormat::RomanLower(_) => write!(f, "Roman Lowercase Numerals"),
|
||||||
|
CounterFormat::CyrillicUpper(_) => write!(f, "Cyrillic Uppercase Letters"),
|
||||||
|
CounterFormat::CyrillicLower(_) => write!(f, "Cyrillic Lowercase Letters"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a character-based counter. Provides a unified interface for
|
||||||
|
/// handling different formats of counters.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CharacterCounter {
|
||||||
|
/// The format of counter (e.g., ANSI, Cyrillic, Roman, digits).
|
||||||
|
format: CounterFormat,
|
||||||
|
|
||||||
|
/// The initial numeric value of the counter, used to reset the counter.
|
||||||
|
start: u32,
|
||||||
|
|
||||||
|
/// The current numeric value of the counter.
|
||||||
|
state: u32,
|
||||||
|
|
||||||
|
/// The increment step size when advancing the counter.
|
||||||
|
step: u32,
|
||||||
|
|
||||||
|
/// The minimum width of the generated string, padded with leading zeros.
|
||||||
|
width: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CharacterCounter {
|
||||||
|
/// Creates a new `CharacterCounter` instance.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `format` - the format of counter (e.g., ANSI, Cyrillic, Roman, digits).
|
||||||
|
/// * `start` - the initial numeric value of the counter.
|
||||||
|
/// * `step` - the increment step size when advancing the counter.
|
||||||
|
/// * `width` - the minimum width of the generated string, padded with leading zeros.
|
||||||
|
pub fn new(format: CounterFormat, start: u32, step: u32, width: usize) -> Self {
|
||||||
|
Self { format, start, state: start, step, width }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the `CharacterCounter` instance with the parameters set in
|
||||||
|
/// builder.
|
||||||
|
pub fn update_from(&mut self, builder: CounterBuilder) {
|
||||||
|
if self.format != builder.format() {
|
||||||
|
self.format = builder.format();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(start) = builder.start() {
|
||||||
|
self.start = start;
|
||||||
|
self.state = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(step) = builder.step() {
|
||||||
|
self.step = step;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(width) = builder.width() {
|
||||||
|
self.width = width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Counter for CharacterCounter {
|
||||||
|
fn write_value(&self, buf: &mut impl fmt::Write) -> fmt::Result {
|
||||||
|
self.format.value_to_buffer(self.state, self.width, buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance(&mut self) {
|
||||||
|
self.state += self.step;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restart(&mut self) {
|
||||||
|
self.state = self.start;
|
||||||
|
}
|
||||||
|
}
|
||||||
183
yazi-actor/src/mgr/bulk_rename/counters/roman.rs
Normal file
183
yazi-actor/src/mgr/bulk_rename/counters/roman.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
//! This module provides functionality for managing Roman numeral counters
|
||||||
|
//! for both uppercase and lowercase Roman numerals.
|
||||||
|
|
||||||
|
use super::{CounterFormatter, LOWERCASE, UPPERCASE};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// A lookup table for uppercase Roman numerals and their values.
|
||||||
|
const UPPERCASE_ROMAN_NUMERALS: [(&str, u32); 13] = [
|
||||||
|
("M", 1000),
|
||||||
|
("CM", 900),
|
||||||
|
("D", 500),
|
||||||
|
("CD", 400),
|
||||||
|
("C", 100),
|
||||||
|
("XC", 90),
|
||||||
|
("L", 50),
|
||||||
|
("XL", 40),
|
||||||
|
("X", 10),
|
||||||
|
("IX", 9),
|
||||||
|
("V", 5),
|
||||||
|
("IV", 4),
|
||||||
|
("I", 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A lookup table for lowercase Roman numerals and their values.
|
||||||
|
const LOWERCASE_ROMAN_NUMERALS: [(&str, u32); 13] = [
|
||||||
|
("m", 1000),
|
||||||
|
("cm", 900),
|
||||||
|
("d", 500),
|
||||||
|
("cd", 400),
|
||||||
|
("c", 100),
|
||||||
|
("xc", 90),
|
||||||
|
("l", 50),
|
||||||
|
("xl", 40),
|
||||||
|
("x", 10),
|
||||||
|
("ix", 9),
|
||||||
|
("v", 5),
|
||||||
|
("iv", 4),
|
||||||
|
("i", 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A helper structure for generating uppercase Roman numerals (e.g., I, II, III, IV, V, ...).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct RomanUpper;
|
||||||
|
|
||||||
|
/// A helper structure for generating lowercase Roman numerals (e.g., i, ii, iii, iv, v, ...).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct RomanLower;
|
||||||
|
|
||||||
|
impl_counter_formatter! { RomanUpper, UPPERCASE }
|
||||||
|
impl_counter_formatter! { RomanLower, LOWERCASE }
|
||||||
|
|
||||||
|
/// Converts Roman numerals (e.g. I, II, III) to their corresponding numeric values.
|
||||||
|
///
|
||||||
|
/// The `UPPERCASE` constant determines whether the string should be validated
|
||||||
|
/// as uppercase or lowercase.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Returns `Some(u32)` if conversion is successful; otherwise, returns `None`.
|
||||||
|
#[inline]
|
||||||
|
fn convert_letters_to_number<const UPPERCASE: bool>(start: &str) -> Option<u32> {
|
||||||
|
if invalid_string::<UPPERCASE>(start) {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let roman_numerals =
|
||||||
|
if UPPERCASE { &UPPERCASE_ROMAN_NUMERALS } else { &LOWERCASE_ROMAN_NUMERALS };
|
||||||
|
|
||||||
|
let mut num = 0;
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < start.len() {
|
||||||
|
if i + 1 < start.len() {
|
||||||
|
if let Some(&(_, value)) = roman_numerals.iter().find(|&&(s, _)| s == &start[i..=i + 1]) {
|
||||||
|
num += value;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(&(_, value)) = roman_numerals.iter().find(|&&(s, _)| s == &start[i..=i]) {
|
||||||
|
num += value;
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(num)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the numeric value as Roman numerals (e.g., 1 → "I", 4 → "IV") into the
|
||||||
|
/// provided buffer.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `num` - The numeric value to convert.
|
||||||
|
/// * `width` - The minimum width of the generated string, padded with zeros if necessary.
|
||||||
|
/// * `buf` - The buffer to write the resulting string into.
|
||||||
|
#[inline]
|
||||||
|
fn write_number_as_letters<const UPPERCASE: bool>(
|
||||||
|
mut num: u32,
|
||||||
|
width: usize,
|
||||||
|
buf: &mut impl fmt::Write,
|
||||||
|
) -> fmt::Result {
|
||||||
|
if num == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let roman_numerals =
|
||||||
|
if UPPERCASE { &UPPERCASE_ROMAN_NUMERALS } else { &LOWERCASE_ROMAN_NUMERALS };
|
||||||
|
|
||||||
|
let mut stack_buf = ['0'; 10];
|
||||||
|
let mut length = 0;
|
||||||
|
|
||||||
|
let mut iter = roman_numerals.iter().peekable();
|
||||||
|
'outer: while let Some(&&(roman, value)) = iter.peek() {
|
||||||
|
'inner: loop {
|
||||||
|
if num < value {
|
||||||
|
break 'inner;
|
||||||
|
}
|
||||||
|
let final_length = length + roman.len();
|
||||||
|
if final_length > stack_buf.len() {
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
for (char_ref, char) in stack_buf[length..final_length].iter_mut().zip(roman.chars()) {
|
||||||
|
*char_ref = char
|
||||||
|
}
|
||||||
|
num -= value;
|
||||||
|
length += roman.len();
|
||||||
|
}
|
||||||
|
iter.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if num > 0 {
|
||||||
|
let mut vec_buf = Vec::with_capacity(20);
|
||||||
|
vec_buf.extend_from_slice(&stack_buf[..length]);
|
||||||
|
|
||||||
|
for &(roman, value) in iter {
|
||||||
|
while num >= value {
|
||||||
|
vec_buf.extend(roman.chars());
|
||||||
|
num -= value;
|
||||||
|
length += roman.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in vec_buf.len()..width {
|
||||||
|
buf.write_char('0')?;
|
||||||
|
}
|
||||||
|
for &c in vec_buf.iter() {
|
||||||
|
buf.write_char(c)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _ in length..width {
|
||||||
|
buf.write_char('0')?;
|
||||||
|
}
|
||||||
|
for &c in stack_buf[..length].iter() {
|
||||||
|
buf.write_char(c)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a string is non-empty and consists only of valid
|
||||||
|
/// uppercase or lowercase Roman numerals.
|
||||||
|
///
|
||||||
|
/// The `UPPERCASE` constant determines whether to check uppercase or lowercase letters.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Returns `true` if the string is invalid; otherwise, returns `false`.
|
||||||
|
#[inline]
|
||||||
|
fn invalid_string<const UPPERCASE: bool>(str: &str) -> bool {
|
||||||
|
if str.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let valid_chars = if UPPERCASE {
|
||||||
|
['M', 'D', 'C', 'L', 'X', 'V', 'I']
|
||||||
|
} else {
|
||||||
|
['m', 'd', 'c', 'l', 'x', 'v', 'i']
|
||||||
|
};
|
||||||
|
|
||||||
|
!str.chars().all(|c| valid_chars.contains(&c))
|
||||||
|
}
|
||||||
177
yazi-actor/src/mgr/bulk_rename/counters/test.rs
Normal file
177
yazi-actor/src/mgr/bulk_rename/counters/test.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const DIGITS_VALUES: [&str; 100] = [
|
||||||
|
"000", "001", "002", "003", "004", "005", "006", "007", "008", "009", "010", "011", "012", "013",
|
||||||
|
"014", "015", "016", "017", "018", "019", "020", "021", "022", "023", "024", "025", "026", "027",
|
||||||
|
"028", "029", "030", "031", "032", "033", "034", "035", "036", "037", "038", "039", "040", "041",
|
||||||
|
"042", "043", "044", "045", "046", "047", "048", "049", "050", "051", "052", "053", "054", "055",
|
||||||
|
"056", "057", "058", "059", "060", "061", "062", "063", "064", "065", "066", "067", "068", "069",
|
||||||
|
"070", "071", "072", "073", "074", "075", "076", "077", "078", "079", "080", "081", "082", "083",
|
||||||
|
"084", "085", "086", "087", "088", "089", "090", "091", "092", "093", "094", "095", "096", "097",
|
||||||
|
"098", "099",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_digits_advance_100_iterations() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let counter = Digits;
|
||||||
|
for (idx, &expected_value) in DIGITS_VALUES.iter().enumerate() {
|
||||||
|
let _ = counter.value_to_buffer(idx as u32, 3, &mut buf);
|
||||||
|
assert_eq!(expected_value, &buf);
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (idx, &expected_value) in DIGITS_VALUES.iter().enumerate() {
|
||||||
|
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPPERCASE_ANSI_VALUES: [&str; 100] = [
|
||||||
|
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S",
|
||||||
|
"T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ",
|
||||||
|
"AK", "AL", "AM", "AN", "AO", "AP", "AQ", "AR", "AS", "AT", "AU", "AV", "AW", "AX", "AY", "AZ",
|
||||||
|
"BA", "BB", "BC", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BK", "BL", "BM", "BN", "BO", "BP",
|
||||||
|
"BQ", "BR", "BS", "BT", "BU", "BV", "BW", "BX", "BY", "BZ", "CA", "CB", "CC", "CD", "CE", "CF",
|
||||||
|
"CG", "CH", "CI", "CJ", "CK", "CL", "CM", "CN", "CO", "CP", "CQ", "CR", "CS", "CT", "CU", "CV",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOWERCASE_ANSI_VALUES: [&str; 100] = [
|
||||||
|
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s",
|
||||||
|
"t", "u", "v", "w", "x", "y", "z", "aa", "ab", "ac", "ad", "ae", "af", "ag", "ah", "ai", "aj",
|
||||||
|
"ak", "al", "am", "an", "ao", "ap", "aq", "ar", "as", "at", "au", "av", "aw", "ax", "ay", "az",
|
||||||
|
"ba", "bb", "bc", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bk", "bl", "bm", "bn", "bo", "bp",
|
||||||
|
"bq", "br", "bs", "bt", "bu", "bv", "bw", "bx", "by", "bz", "ca", "cb", "cc", "cd", "ce", "cf",
|
||||||
|
"cg", "ch", "ci", "cj", "ck", "cl", "cm", "cn", "co", "cp", "cq", "cr", "cs", "ct", "cu", "cv",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ansi_upper_advance_100_iterations() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let counter = AnsiUpper;
|
||||||
|
for (idx, &expected_value) in UPPERCASE_ANSI_VALUES.iter().enumerate() {
|
||||||
|
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
|
||||||
|
assert_eq!(expected_value, &buf);
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (idx, &expected_value) in UPPERCASE_ANSI_VALUES.iter().enumerate() {
|
||||||
|
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ansi_lower_advance_100_iterations() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let counter = AnsiLower;
|
||||||
|
for (idx, &expected_value) in LOWERCASE_ANSI_VALUES.iter().enumerate() {
|
||||||
|
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
|
||||||
|
assert_eq!(expected_value, &buf);
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (idx, &expected_value) in LOWERCASE_ANSI_VALUES.iter().enumerate() {
|
||||||
|
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPPERCASE_ROMAN_VALUES: [&str; 100] = [
|
||||||
|
"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII", "XIII", "XIV", "XV",
|
||||||
|
"XVI", "XVII", "XVIII", "XIX", "XX", "XXI", "XXII", "XXIII", "XXIV", "XXV", "XXVI", "XXVII",
|
||||||
|
"XXVIII", "XXIX", "XXX", "XXXI", "XXXII", "XXXIII", "XXXIV", "XXXV", "XXXVI", "XXXVII",
|
||||||
|
"XXXVIII", "XXXIX", "XL", "XLI", "XLII", "XLIII", "XLIV", "XLV", "XLVI", "XLVII", "XLVIII",
|
||||||
|
"XLIX", "L", "LI", "LII", "LIII", "LIV", "LV", "LVI", "LVII", "LVIII", "LIX", "LX", "LXI",
|
||||||
|
"LXII", "LXIII", "LXIV", "LXV", "LXVI", "LXVII", "LXVIII", "LXIX", "LXX", "LXXI", "LXXII",
|
||||||
|
"LXXIII", "LXXIV", "LXXV", "LXXVI", "LXXVII", "LXXVIII", "LXXIX", "LXXX", "LXXXI", "LXXXII",
|
||||||
|
"LXXXIII", "LXXXIV", "LXXXV", "LXXXVI", "LXXXVII", "LXXXVIII", "LXXXIX", "XC", "XCI", "XCII",
|
||||||
|
"XCIII", "XCIV", "XCV", "XCVI", "XCVII", "XCVIII", "XCIX", "C",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOWERCASE_ROMAN_VALUES: [&str; 100] = [
|
||||||
|
"i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii", "xiii", "xiv", "xv",
|
||||||
|
"xvi", "xvii", "xviii", "xix", "xx", "xxi", "xxii", "xxiii", "xxiv", "xxv", "xxvi", "xxvii",
|
||||||
|
"xxviii", "xxix", "xxx", "xxxi", "xxxii", "xxxiii", "xxxiv", "xxxv", "xxxvi", "xxxvii",
|
||||||
|
"xxxviii", "xxxix", "xl", "xli", "xlii", "xliii", "xliv", "xlv", "xlvi", "xlvii", "xlviii",
|
||||||
|
"xlix", "l", "li", "lii", "liii", "liv", "lv", "lvi", "lvii", "lviii", "lix", "lx", "lxi",
|
||||||
|
"lxii", "lxiii", "lxiv", "lxv", "lxvi", "lxvii", "lxviii", "lxix", "lxx", "lxxi", "lxxii",
|
||||||
|
"lxxiii", "lxxiv", "lxxv", "lxxvi", "lxxvii", "lxxviii", "lxxix", "lxxx", "lxxxi", "lxxxii",
|
||||||
|
"lxxxiii", "lxxxiv", "lxxxv", "lxxxvi", "lxxxvii", "lxxxviii", "lxxxix", "xc", "xci", "xcii",
|
||||||
|
"xciii", "xciv", "xcv", "xcvi", "xcvii", "xcviii", "xcix", "c",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_roman_upper_advance_100_iterations() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let counter = RomanUpper;
|
||||||
|
for (idx, &expected_value) in UPPERCASE_ROMAN_VALUES.iter().enumerate() {
|
||||||
|
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
|
||||||
|
assert_eq!(expected_value, &buf);
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (idx, &expected_value) in UPPERCASE_ROMAN_VALUES.iter().enumerate() {
|
||||||
|
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_roman_lower_advance_100_iterations() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let counter = RomanLower;
|
||||||
|
for (idx, &expected_value) in LOWERCASE_ROMAN_VALUES.iter().enumerate() {
|
||||||
|
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
|
||||||
|
assert_eq!(expected_value, &buf);
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (idx, &expected_value) in LOWERCASE_ROMAN_VALUES.iter().enumerate() {
|
||||||
|
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPPERCASE_CYRILLIC_VALUES: [&str; 100] = [
|
||||||
|
"А", "Б", "В", "Г", "Д", "Е", "Ж", "З", "И", "К", "Л", "М", "Н", "О", "П", "Р", "С", "Т", "У",
|
||||||
|
"Ф", "Х", "Ц", "Ч", "Ш", "Щ", "Э", "Ю", "Я", "АА", "АБ", "АВ", "АГ", "АД", "АЕ", "АЖ", "АЗ",
|
||||||
|
"АИ", "АК", "АЛ", "АМ", "АН", "АО", "АП", "АР", "АС", "АТ", "АУ", "АФ", "АХ", "АЦ", "АЧ", "АШ",
|
||||||
|
"АЩ", "АЭ", "АЮ", "АЯ", "БА", "ББ", "БВ", "БГ", "БД", "БЕ", "БЖ", "БЗ", "БИ", "БК", "БЛ", "БМ",
|
||||||
|
"БН", "БО", "БП", "БР", "БС", "БТ", "БУ", "БФ", "БХ", "БЦ", "БЧ", "БШ", "БЩ", "БЭ", "БЮ", "БЯ",
|
||||||
|
"ВА", "ВБ", "ВВ", "ВГ", "ВД", "ВЕ", "ВЖ", "ВЗ", "ВИ", "ВК", "ВЛ", "ВМ", "ВН", "ВО", "ВП", "ВР",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOWERCASE_CYRILLIC_VALUES: [&str; 100] = [
|
||||||
|
"а", "б", "в", "г", "д", "е", "ж", "з", "и", "к", "л", "м", "н", "о", "п", "р", "с", "т", "у",
|
||||||
|
"ф", "х", "ц", "ч", "ш", "щ", "э", "ю", "я", "аа", "аб", "ав", "аг", "ад", "ае", "аж", "аз",
|
||||||
|
"аи", "ак", "ал", "ам", "ан", "ао", "ап", "ар", "ас", "ат", "ау", "аф", "ах", "ац", "ач", "аш",
|
||||||
|
"ащ", "аэ", "аю", "ая", "ба", "бб", "бв", "бг", "бд", "бе", "бж", "бз", "би", "бк", "бл", "бм",
|
||||||
|
"бн", "бо", "бп", "бр", "бс", "бт", "бу", "бф", "бх", "бц", "бч", "бш", "бщ", "бэ", "бю", "бя",
|
||||||
|
"ва", "вб", "вв", "вг", "вд", "ве", "вж", "вз", "ви", "вк", "вл", "вм", "вн", "во", "вп", "вр",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cyrillic_upper_advance_100_iterations() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let counter = CyrillicUpper;
|
||||||
|
for (idx, &expected_value) in UPPERCASE_CYRILLIC_VALUES.iter().enumerate() {
|
||||||
|
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
|
||||||
|
assert_eq!(expected_value, &buf);
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (idx, &expected_value) in UPPERCASE_CYRILLIC_VALUES.iter().enumerate() {
|
||||||
|
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cyrillic_lower_advance_100_iterations() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let counter = CyrillicLower;
|
||||||
|
for (idx, &expected_value) in LOWERCASE_CYRILLIC_VALUES.iter().enumerate() {
|
||||||
|
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
|
||||||
|
assert_eq!(expected_value, &buf);
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (idx, &expected_value) in LOWERCASE_CYRILLIC_VALUES.iter().enumerate() {
|
||||||
|
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
693
yazi-actor/src/mgr/bulk_rename/filename_template/mod.rs
Normal file
693
yazi-actor/src/mgr/bulk_rename/filename_template/mod.rs
Normal file
|
|
@ -0,0 +1,693 @@
|
||||||
|
use std::{
|
||||||
|
error::Error,
|
||||||
|
fmt,
|
||||||
|
num::{IntErrorKind, NonZero, ParseIntError},
|
||||||
|
ops::Range,
|
||||||
|
str::FromStr,
|
||||||
|
str::Split,
|
||||||
|
};
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
use super::counters::{
|
||||||
|
AnsiLower, AnsiUpper, CharacterCounter, CounterFormat, CounterFormatter, CyrillicLower,
|
||||||
|
CyrillicUpper, Digits, RomanLower, RomanUpper,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test;
|
||||||
|
#[cfg(test)]
|
||||||
|
use super::counters::Counter;
|
||||||
|
|
||||||
|
/// A byte range within a string.
|
||||||
|
pub type Span = Range<usize>;
|
||||||
|
|
||||||
|
impl<'a> TryFrom<&'a str> for ParsedLine<'a> {
|
||||||
|
type Error = ParseError<'a>;
|
||||||
|
|
||||||
|
fn try_from(input: &'a str) -> Result<Self, Self::Error> {
|
||||||
|
match Template::parse(input) {
|
||||||
|
Ok(template) => Ok(ParsedLine::Countable(template)),
|
||||||
|
Err(error) => match error {
|
||||||
|
TemplateError::NotCounter => Ok(ParsedLine::Fixed(input)),
|
||||||
|
TemplateError::Parse(error) => Err(error),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed input line representing either plain text or a template
|
||||||
|
/// combining static text fragments and counters.
|
||||||
|
pub enum ParsedLine<'a> {
|
||||||
|
/// A variant used when the input line contains no counters.
|
||||||
|
///
|
||||||
|
/// This avoids allocating a `Vec` for `TemplatePart` when no dynamic parts are present.
|
||||||
|
Fixed(&'a str),
|
||||||
|
|
||||||
|
/// A variant used when the input line contains a template with
|
||||||
|
/// static text fragments and counters.
|
||||||
|
Countable(Template<'a>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A template (pattern) that combines static text fragments and counters.
|
||||||
|
///
|
||||||
|
/// This structure holds a list of parts, where each part is either plain text
|
||||||
|
/// or a placeholder for a dynamically generated counter.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Template<'a> {
|
||||||
|
/// A sequence of parts, each of which is either static text or a `CounterBuilder`.
|
||||||
|
parts: Vec<TemplatePart<'a>>,
|
||||||
|
|
||||||
|
/// Number of `CounterBuilder` parts contained in `parts`.
|
||||||
|
///
|
||||||
|
/// Guaranteed to be non-zero for type safety: if no counters are found,
|
||||||
|
/// we return `ParsedLine::Fixed` instead of creating a `Template`.
|
||||||
|
counter_count: NonZero<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the elements of a countable template (pattern).
|
||||||
|
///
|
||||||
|
/// A `TemplatePart` can be either static text or a `CounterBuilder`, which
|
||||||
|
/// allows for the deferred creation or updating of a counter when needed.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TemplatePart<'a> {
|
||||||
|
/// Static text within the template.
|
||||||
|
Text(&'a str),
|
||||||
|
|
||||||
|
/// A `CounterBuilder` that can be used to create or modify a counter.
|
||||||
|
CounterBuilder(CounterBuilder),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Template<'_> {
|
||||||
|
/// Returns the number of CounterBuilders within [`Template`]
|
||||||
|
pub fn counter_count(&self) -> usize {
|
||||||
|
self.counter_count.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of CounterBuilders within [`Template`]
|
||||||
|
pub fn parts(&self) -> &Vec<TemplatePart<'_>> {
|
||||||
|
&self.parts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a string into a `Template`, identifying static text and counter
|
||||||
|
/// placeholders.
|
||||||
|
///
|
||||||
|
/// Parses the input text, replacing countable elements with `CounterBuilder`
|
||||||
|
/// instances. Returns `Err(TemplateError)` if no counters are found or
|
||||||
|
/// parsing fails.
|
||||||
|
///
|
||||||
|
/// # Counter Pattern Format
|
||||||
|
///
|
||||||
|
/// Format: `%{<COUNT_TYPE>,<START_VALUE>,<COUNT_STEP>,<COUNT_WIDTH>}`
|
||||||
|
///
|
||||||
|
/// * `<COUNT_TYPE>`: A single character indicating the counter type:
|
||||||
|
///
|
||||||
|
/// - `N`, `n`, `D`, `d` → Numeric digits.
|
||||||
|
/// - `A` → Uppercase ANSI letters.
|
||||||
|
/// - `a` → Lowercase ANSI letters.
|
||||||
|
/// - `R` → Uppercase Roman numerals.
|
||||||
|
/// - `r` → Lowercase Roman numerals.
|
||||||
|
/// - `C` → Uppercase Cyrillic letters.
|
||||||
|
/// - `c` → Lowercase Cyrillic letters.
|
||||||
|
///
|
||||||
|
/// * `<START_VALUE>` (optional): Initial value, either as a number (e.g.,
|
||||||
|
/// `1`, `2`, etc.) or a value corresponding to the counter type:
|
||||||
|
///
|
||||||
|
/// - `A`, `B`, `AA` (ANSI uppercase)
|
||||||
|
/// - `a`, `b`, `aa` (ANSI lowercase)
|
||||||
|
/// - `I`, `II`, `III` (Roman uppercase)
|
||||||
|
/// - `i`, `ii`, `iii` (Roman lowercase)
|
||||||
|
/// - `А`, `Б`, `АБ` (Cyrillic uppercase)
|
||||||
|
/// - `а`, `б`, `аб` (Cyrillic lowercase)
|
||||||
|
/// - `_` for unspecified.
|
||||||
|
///
|
||||||
|
/// - `<COUNT_STEP>` (optional): Step size, integer (e.g., `2`) or `_` for
|
||||||
|
/// unspecified.
|
||||||
|
///
|
||||||
|
/// - `<COUNT_WIDTH>` (optional): Minimum width with zero-padding. Integer
|
||||||
|
/// (e.g., `3`) or `_` for unspecified.
|
||||||
|
///
|
||||||
|
/// Optional parameters must be specified sequentially: `<START_VALUE>` is
|
||||||
|
/// required if `<COUNT_STEP>` or `<COUNT_WIDTH>` are used, either explicitly
|
||||||
|
/// (e.g., `1`) or with `_`. Omitting earlier parameters with commas (e.g.,
|
||||||
|
/// `%{N,,2}` or `%{N,,,4}`) is invalid. Use `%{N,_,2}` or `%{N,_,_,4}`
|
||||||
|
/// instead. Defaults (`1` for unset values) apply in
|
||||||
|
/// `CounterBuilder::build()`.
|
||||||
|
///
|
||||||
|
/// ## Escaping `%{`
|
||||||
|
///
|
||||||
|
/// To include a literal `%{` in the output without interpreting it as a counter,
|
||||||
|
/// escape it by writing `%%{`. The leading `%%` will be interpreted as a single
|
||||||
|
/// `%` followed by a literal `{`. For example:
|
||||||
|
///
|
||||||
|
/// - `file_%%{name}` → `file_%{name}`
|
||||||
|
///
|
||||||
|
/// # Examples (given as CounterBuilder fields)
|
||||||
|
///
|
||||||
|
/// - `%{N,1}` → start=1, step=None, width=None
|
||||||
|
/// - `%{N,1,3}` → start=1, step=3, width=None
|
||||||
|
/// - `%{N,_,2}` → start=None, step=2, width=None
|
||||||
|
/// - `%{N,_,_,4}` → start=None, step=None, width=4
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - `TemplateError::NotCounter`: No counters found.
|
||||||
|
/// - `TemplateError::Parse`: Invalid parameters (e.g., `%{N,,2}`).
|
||||||
|
fn parse(input: &str) -> Result<Template<'_>, TemplateError<'_>> {
|
||||||
|
let mut chars = input.char_indices();
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut parsed_start_byte_idx = 0;
|
||||||
|
let mut counter_count = 0;
|
||||||
|
|
||||||
|
while let Some((count_start_byte_idx, char)) = chars.next() {
|
||||||
|
if char == '%' {
|
||||||
|
let mut percent_count = 1;
|
||||||
|
while let Some((current_byte_idx, char)) = chars.next() {
|
||||||
|
match char {
|
||||||
|
'%' => percent_count += 1,
|
||||||
|
'{' => {
|
||||||
|
// If we have sequence of percent sign (`%%{` or `%%%{` or `%%%%{` and so on)
|
||||||
|
// so there is escaping of `%{`
|
||||||
|
if percent_count > 1 {
|
||||||
|
// current_byte_idx points to the start of '{', so we need to subtract 2 for point
|
||||||
|
// to the star '%%{'
|
||||||
|
if current_byte_idx - parsed_start_byte_idx > 0 {
|
||||||
|
let before_match = &input[parsed_start_byte_idx..current_byte_idx - 2];
|
||||||
|
parts.push(TemplatePart::Text(before_match));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(TemplatePart::Text("%{"));
|
||||||
|
|
||||||
|
// current_byte_idx points to the start of '{', so we need to add the length of '{'
|
||||||
|
parsed_start_byte_idx = current_byte_idx + 1;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
let mut counter_end_found = false;
|
||||||
|
for (count_end_byte_idx, char) in chars.by_ref() {
|
||||||
|
if char == '}' {
|
||||||
|
counter_end_found = true;
|
||||||
|
|
||||||
|
// counter starts from `count_start_byte_idx + length of %{` till `count_end_byte_idx`
|
||||||
|
let span = count_start_byte_idx + 2..count_end_byte_idx;
|
||||||
|
let builder = Self::parse_counter(span, input)?;
|
||||||
|
|
||||||
|
if count_start_byte_idx - parsed_start_byte_idx > 0 {
|
||||||
|
let before_match = &input[parsed_start_byte_idx..count_start_byte_idx];
|
||||||
|
parts.push(TemplatePart::Text(before_match));
|
||||||
|
}
|
||||||
|
parts.push(TemplatePart::CounterBuilder(builder));
|
||||||
|
parsed_start_byte_idx = count_end_byte_idx + 1;
|
||||||
|
counter_count += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !counter_end_found {
|
||||||
|
return Err(TemplateError::Parse(ParseError {
|
||||||
|
input,
|
||||||
|
span: input.len()..input.len(),
|
||||||
|
reason: "Unclosed delimiter",
|
||||||
|
expected: Some("}"),
|
||||||
|
found: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no countable elements were found, return an error.
|
||||||
|
if counter_count == 0 {
|
||||||
|
Err(TemplateError::NotCounter)
|
||||||
|
} else {
|
||||||
|
// Add any remaining text after the last match.
|
||||||
|
if parsed_start_byte_idx < input.len() {
|
||||||
|
let after_last_match = &input[parsed_start_byte_idx..];
|
||||||
|
parts.push(TemplatePart::Text(after_last_match));
|
||||||
|
}
|
||||||
|
Ok(Template {
|
||||||
|
parts,
|
||||||
|
// SAFETY: We checked that counter_count is not equal to zero
|
||||||
|
counter_count: unsafe { NonZero::<usize>::new_unchecked(counter_count) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a counter parameters from a substring and creates a `CounterBuilder`.
|
||||||
|
///
|
||||||
|
/// Takes a `span` range within the `input` string, extracts a counter parameters in the format
|
||||||
|
/// `[<COUNT_TYPE>,<START_VALUE>,<COUNT_STEP>,<COUNT_WIDTH>]`, and returns a configured
|
||||||
|
/// `CounterBuilder`. Returns `ParseError` if parsing fails.
|
||||||
|
///
|
||||||
|
/// See `Template::parse` for more information
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `span` - The range of the counter parameters within `input`.
|
||||||
|
/// * `input` - The full input string.
|
||||||
|
fn parse_counter(span: Span, input: &str) -> Result<CounterBuilder, ParseError<'_>> {
|
||||||
|
let Range { start, end } = span;
|
||||||
|
let mut iter = Parts::new(span, input);
|
||||||
|
let (format_span, format) = iter.next().unwrap_or((start..end, ""));
|
||||||
|
|
||||||
|
let builder = CounterBuilder::default()
|
||||||
|
.try_set_format(input, format_span, format)?
|
||||||
|
.try_set_start(input, iter.next())?
|
||||||
|
.try_set_step(input, iter.next())?
|
||||||
|
.try_set_width(input, iter.next())?;
|
||||||
|
|
||||||
|
if let Some((format_span, _)) = iter.next() {
|
||||||
|
return Err(ParseError {
|
||||||
|
input,
|
||||||
|
span: format_span.start - 1..end,
|
||||||
|
reason: "Extra arguments",
|
||||||
|
expected: Some("no additional arguments"),
|
||||||
|
found: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enum representing errors that can occur when parsing countable
|
||||||
|
/// [Template].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TemplateError<'a> {
|
||||||
|
/// Error indicating that no counter was found in the pattern.
|
||||||
|
NotCounter,
|
||||||
|
/// Error indicating invalid input for a counter configuration.
|
||||||
|
Parse(ParseError<'a>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<ParseError<'a>> for TemplateError<'a> {
|
||||||
|
fn from(err: ParseError<'a>) -> Self {
|
||||||
|
TemplateError::Parse(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents an error encountered while parsing a counter
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ParseError<'a> {
|
||||||
|
/// The full input string where the error occurred.
|
||||||
|
pub input: &'a str,
|
||||||
|
|
||||||
|
/// The byte range in the input text where the error occurred.
|
||||||
|
pub span: Span,
|
||||||
|
|
||||||
|
/// A brief description of what went wrong.
|
||||||
|
pub reason: &'static str,
|
||||||
|
|
||||||
|
/// An optional hint about the expected input.
|
||||||
|
pub expected: Option<&'static str>,
|
||||||
|
|
||||||
|
/// An optional string showing what was actually found.
|
||||||
|
pub found: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ParseError<'_> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let Range { start, end } = self.span;
|
||||||
|
write!(f, "Error: {}", self.reason)?;
|
||||||
|
|
||||||
|
if let Some(expected) = &self.expected {
|
||||||
|
write!(f, ". Expected: '{expected}'")?;
|
||||||
|
}
|
||||||
|
if let Some(found) = &self.found {
|
||||||
|
write!(f, ", found: '{found}'")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(f, "\n\n{}", self.input)?;
|
||||||
|
|
||||||
|
let offset = UnicodeWidthStr::width(&self.input[..start]);
|
||||||
|
let length = UnicodeWidthStr::width(&self.input[start..end]).max(1);
|
||||||
|
|
||||||
|
writeln!(f, "{:>offset$}{:^>length$}", "", "", offset = offset, length = length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for ParseError<'_> {}
|
||||||
|
|
||||||
|
/// An iterator over comma-separated segments of a string slice, returning
|
||||||
|
/// both the segment and its byte range (`Span`) relative to the original full
|
||||||
|
/// input string.
|
||||||
|
///
|
||||||
|
/// This is used for parsing parameter lists such as `%{N,_,2,3}` where each
|
||||||
|
/// value needs to be associated with its exact location in the original string
|
||||||
|
/// for precise error reporting.
|
||||||
|
pub struct Parts<'a> {
|
||||||
|
parts: Split<'a, char>,
|
||||||
|
current_idx: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Parts<'a> {
|
||||||
|
/// Creates a new `Parts` iterator over the portion of `input` defined by `span`.
|
||||||
|
///
|
||||||
|
/// The `span` defines the byte range into the original string, and the
|
||||||
|
/// returned segments will report their positions relative to that original input.
|
||||||
|
pub fn new(span: Span, input: &'a str) -> Self {
|
||||||
|
let current_idx = span.start;
|
||||||
|
let parts = &input[span];
|
||||||
|
|
||||||
|
Self { parts: parts.split(','), current_idx }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Iterator for Parts<'a> {
|
||||||
|
/// An item representing a single comma-separated segment and its byte range.
|
||||||
|
///
|
||||||
|
/// - `Span`: the byte range of the segment in the original input string,
|
||||||
|
/// used for precise error reporting.
|
||||||
|
///
|
||||||
|
/// - `&'a str`: the actual content of the segment.
|
||||||
|
type Item = (Span, &'a str);
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
let part = self.parts.next()?;
|
||||||
|
|
||||||
|
let next = Some((self.current_idx..self.current_idx + part.len(), part));
|
||||||
|
self.current_idx += part.len() + 1;
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A builder for constructing `CharacterCounter` instances.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct CounterBuilder {
|
||||||
|
/// The format of counter to create.
|
||||||
|
format: CounterFormat,
|
||||||
|
|
||||||
|
/// The initial counter value.
|
||||||
|
start: Option<u32>,
|
||||||
|
|
||||||
|
/// The step size for incrementing the counter.
|
||||||
|
step: Option<u32>,
|
||||||
|
|
||||||
|
/// The minimum output width, zero-padded if needed.
|
||||||
|
width: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CounterBuilder {
|
||||||
|
/// Creates a new `CounterBuilder` instance with default values
|
||||||
|
#[inline]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
CounterBuilder::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected counter format.
|
||||||
|
#[inline]
|
||||||
|
pub fn format(&self) -> CounterFormat {
|
||||||
|
self.format
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the initial value of the counter.
|
||||||
|
#[inline]
|
||||||
|
pub fn start(&self) -> Option<u32> {
|
||||||
|
self.start
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the step size for advancing the counter.
|
||||||
|
#[inline]
|
||||||
|
pub fn step(&self) -> Option<u32> {
|
||||||
|
self.step
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the minimum width of the generated output.
|
||||||
|
#[inline]
|
||||||
|
pub fn width(&self) -> Option<usize> {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses and sets the counter format from a single-character string.
|
||||||
|
///
|
||||||
|
/// This method extracts and validates a counter format from a potentially
|
||||||
|
/// whitespace-padded string. If parsing fails, it returns a [`ParseError`]
|
||||||
|
/// with the span adjusted to exclude leading and trailing whitespace.
|
||||||
|
///
|
||||||
|
/// # Supported Characters
|
||||||
|
///
|
||||||
|
/// - `'D'`, `'d'`, `'N'`, `'n'` → [`Digits`]
|
||||||
|
/// - `'A'` → [`AnsiUpper`]
|
||||||
|
/// - `'a'` → [`AnsiLower`]
|
||||||
|
/// - `'R'` → [`RomanUpper`]
|
||||||
|
/// - `'r'` → [`RomanLower`]
|
||||||
|
/// - `'C'` → [`CyrillicUpper`]
|
||||||
|
/// - `'c'` → [`CyrillicLower`]
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `input` - The full input string
|
||||||
|
///
|
||||||
|
/// * `span` - The range of the counter format within `input`.
|
||||||
|
///
|
||||||
|
/// * `format` - A single-character string (possibly with surrounding whitespaces)
|
||||||
|
/// representing the counter format.
|
||||||
|
#[inline]
|
||||||
|
pub fn try_set_format<'a>(
|
||||||
|
mut self,
|
||||||
|
input: &'a str,
|
||||||
|
span: Span,
|
||||||
|
format: &'a str,
|
||||||
|
) -> Result<Self, ParseError<'a>> {
|
||||||
|
let Range { start, end } = span;
|
||||||
|
let (trim_span, trimmed) = trim_with_range(start..end, format);
|
||||||
|
let format = match trimmed {
|
||||||
|
"D" | "d" | "N" | "n" => CounterFormat::Digits(Digits),
|
||||||
|
"A" => CounterFormat::AnsiUpper(AnsiUpper),
|
||||||
|
"a" => CounterFormat::AnsiLower(AnsiLower),
|
||||||
|
"R" => CounterFormat::RomanUpper(RomanUpper),
|
||||||
|
"r" => CounterFormat::RomanLower(RomanLower),
|
||||||
|
"C" => CounterFormat::CyrillicUpper(CyrillicUpper),
|
||||||
|
"c" => CounterFormat::CyrillicLower(CyrillicLower),
|
||||||
|
"" => {
|
||||||
|
return Err(ParseError {
|
||||||
|
input,
|
||||||
|
span: start..end,
|
||||||
|
reason: "Empty counter kind",
|
||||||
|
expected: Some("one of D, d, N, n, A, a, R, r, C, c"),
|
||||||
|
found: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(ParseError {
|
||||||
|
input,
|
||||||
|
span: trim_span,
|
||||||
|
reason: "Unexpected counter kind",
|
||||||
|
expected: Some("one of D, d, N, n, A, a, R, r, C, c"),
|
||||||
|
found: Some(other),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.format = format;
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses and sets the start value for the counter from an optional,
|
||||||
|
/// possibly whitespace-padded string.
|
||||||
|
///
|
||||||
|
/// The value is first interpreted using the current counter format
|
||||||
|
/// (e.g. digits, letters, Roman numerals). If that fails, it is parsed
|
||||||
|
/// as an integer. An underscore (`_`) means "unspecified" and is ignored.
|
||||||
|
///
|
||||||
|
/// Leading and trailing whitespace is excluded from the error span if parsing fails.
|
||||||
|
///
|
||||||
|
/// # Supported Formats
|
||||||
|
///
|
||||||
|
/// - Digits: `1`, `2`, `100`
|
||||||
|
/// - ANSI Uppercase: `A`, `B`, `AA`
|
||||||
|
/// - ANSI Lowercase: `a`, `b`, `aa`
|
||||||
|
/// - Roman Uppercase: `I`, `II`, `III`
|
||||||
|
/// - Roman Lowercase: `i`, `ii`, `iii`
|
||||||
|
/// - Cyrillic Uppercase: `А`, `Б`, `АБ`
|
||||||
|
/// - Cyrillic Lowercase: `а`, `б`, `аб`
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `input` - The full input string
|
||||||
|
///
|
||||||
|
/// * `start` – Optional `(Span, &str)` pair representing the value and
|
||||||
|
/// its position in the original input.
|
||||||
|
#[inline]
|
||||||
|
pub fn try_set_start<'a>(
|
||||||
|
mut self,
|
||||||
|
input: &'a str,
|
||||||
|
start: Option<(Span, &'a str)>,
|
||||||
|
) -> Result<Self, ParseError<'a>> {
|
||||||
|
self.start = Self::parse_field(input, start, |trimmed| self.format.string_to_value(trimmed))?;
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses and sets the step size for the counter from an optional,
|
||||||
|
/// possibly whitespace-padded string.
|
||||||
|
///
|
||||||
|
/// The value is parsed as an integer. An underscore (`_`) means
|
||||||
|
/// "unspecified" and is ignored.
|
||||||
|
///
|
||||||
|
/// Leading and trailing whitespace is excluded from the error span if parsing fails.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `input` - The full input string
|
||||||
|
///
|
||||||
|
/// * `step` – Optional `(Span, &str)` pair representing the value and
|
||||||
|
/// its position in the original input.
|
||||||
|
#[inline]
|
||||||
|
pub fn try_set_step<'a>(
|
||||||
|
mut self,
|
||||||
|
input: &'a str,
|
||||||
|
step: Option<(Span, &'a str)>,
|
||||||
|
) -> Result<Self, ParseError<'a>> {
|
||||||
|
self.step = Self::parse_field(input, step, |_| None)?;
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses and sets the minimum output width for the counter from an optional,
|
||||||
|
/// possibly whitespace-padded string.
|
||||||
|
///
|
||||||
|
/// The value is parsed as an integer. An underscore (`_`) means
|
||||||
|
/// "unspecified" and is ignored.
|
||||||
|
///
|
||||||
|
/// Leading and trailing whitespace is excluded from the error span if parsing fails.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `input` - The full input string
|
||||||
|
///
|
||||||
|
/// * `width` – Optional `(Span, &str)` pair representing the value and
|
||||||
|
/// its position in the original input.
|
||||||
|
#[inline]
|
||||||
|
pub fn try_set_width<'a>(
|
||||||
|
mut self,
|
||||||
|
input: &'a str,
|
||||||
|
width: Option<(Span, &'a str)>,
|
||||||
|
) -> Result<Self, ParseError<'a>> {
|
||||||
|
self.width = Self::parse_field(input, width, |_| None)?;
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an optional `(Span, &str)` into a typed value with optional custom logic.
|
||||||
|
///
|
||||||
|
/// Steps:
|
||||||
|
///
|
||||||
|
/// 1. Trim leading/trailing whitespace.
|
||||||
|
/// 2. If the result is `_`, return `Ok(None)`.
|
||||||
|
/// 3. If `custom_parse` returns `Some(val)`, use it.
|
||||||
|
/// 4. Otherwise, parse using [`FromStr`] for `T`.
|
||||||
|
///
|
||||||
|
/// On failure, returns a [`ParseError`] with a span pointing to the trimmed region
|
||||||
|
/// (or the full span if the string is empty).
|
||||||
|
///
|
||||||
|
/// # Type Parameters
|
||||||
|
///
|
||||||
|
/// - `T`: The target type, requiring [`FromStr`], [`IntError`], and [`Error`].
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// - `input`: The full input string
|
||||||
|
/// - `field_data`: Optional `(Span, &str)` (position in the original input text and
|
||||||
|
/// the content).
|
||||||
|
/// - `custom_parse`: A fallback parser tried before numeric parsing.
|
||||||
|
#[inline]
|
||||||
|
fn parse_field<'a, T>(
|
||||||
|
input: &'a str,
|
||||||
|
field_data: Option<(Span, &'a str)>,
|
||||||
|
custom_parse: impl FnOnce(&str) -> Option<T>,
|
||||||
|
) -> Result<Option<T>, ParseError<'a>>
|
||||||
|
where
|
||||||
|
T: FromStr,
|
||||||
|
T::Err: IntError + Error,
|
||||||
|
{
|
||||||
|
let Some((original_span, content)) = field_data else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut trim_span, trimmed) = trim_with_range(original_span.clone(), content);
|
||||||
|
|
||||||
|
if trimmed == "_" {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(val) = custom_parse(trimmed) {
|
||||||
|
return Ok(Some(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
match trimmed.parse::<T>() {
|
||||||
|
Ok(v) => Ok(Some(v)),
|
||||||
|
Err(err) => {
|
||||||
|
let reason = match err.kind() {
|
||||||
|
IntErrorKind::Empty => {
|
||||||
|
trim_span = original_span;
|
||||||
|
"Cannot parse integer from empty string"
|
||||||
|
}
|
||||||
|
IntErrorKind::InvalidDigit => "Invalid digit found in string",
|
||||||
|
IntErrorKind::PosOverflow => "Number too large",
|
||||||
|
IntErrorKind::NegOverflow => "Number too small",
|
||||||
|
_ => "Failed to parse integer",
|
||||||
|
};
|
||||||
|
Err(ParseError {
|
||||||
|
input,
|
||||||
|
span: trim_span,
|
||||||
|
reason,
|
||||||
|
expected: Some("digit"),
|
||||||
|
found: Some(trimmed),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds and returns a `CharacterCounter` instance based on the parameters
|
||||||
|
/// set in this builder.
|
||||||
|
///
|
||||||
|
/// If any of these parameters are not set, default values are used:
|
||||||
|
/// - `start`: 1
|
||||||
|
/// - `step`: 1
|
||||||
|
/// - `width`: 1
|
||||||
|
#[inline]
|
||||||
|
pub fn build(self) -> CharacterCounter {
|
||||||
|
CharacterCounter::new(
|
||||||
|
self.format,
|
||||||
|
self.start.unwrap_or(1),
|
||||||
|
self.step.unwrap_or(1),
|
||||||
|
self.width.unwrap_or(1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims leading whitespace from `str` and returns the trimmed slice
|
||||||
|
/// along with an updated `Span` reflecting the new start position.
|
||||||
|
///
|
||||||
|
/// If the string is entirely whitespace, returns an empty slice
|
||||||
|
/// and a zero-length span at the original `span.start`.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// - `span`: The original byte range in the input text.
|
||||||
|
/// - `str`: The substring to trim.
|
||||||
|
fn trim_with_range(span: Span, str: &str) -> (Span, &str) {
|
||||||
|
let Some(mut start) = str.find(|c: char| !c.is_whitespace()) else {
|
||||||
|
return (span.start..span.start, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
start += span.start;
|
||||||
|
let str = str.trim();
|
||||||
|
|
||||||
|
(start..start + str.len(), str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lightweight trait for accessing [`IntErrorKind`] in generic
|
||||||
|
/// number-parsing logic, without depending directly on `ParseIntError`.
|
||||||
|
trait IntError {
|
||||||
|
/// Returns the specific kind of integer parse error.
|
||||||
|
fn kind(&self) -> &IntErrorKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntError for ParseIntError {
|
||||||
|
#[inline]
|
||||||
|
fn kind(&self) -> &IntErrorKind {
|
||||||
|
self.kind()
|
||||||
|
}
|
||||||
|
}
|
||||||
312
yazi-actor/src/mgr/bulk_rename/filename_template/test.rs
Normal file
312
yazi-actor/src/mgr/bulk_rename/filename_template/test.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_creates_default_builder() {
|
||||||
|
let builder = CounterBuilder::new();
|
||||||
|
assert_eq!(builder.format(), CounterFormat::Digits(Digits));
|
||||||
|
assert_eq!(builder.start(), None);
|
||||||
|
assert_eq!(builder.step(), None);
|
||||||
|
assert_eq!(builder.width(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_set_format() {
|
||||||
|
let builder = CounterBuilder::new();
|
||||||
|
|
||||||
|
// Test all supported formats
|
||||||
|
let formats = [
|
||||||
|
("D", " D ", CounterFormat::Digits(Digits)),
|
||||||
|
("d", " d ", CounterFormat::Digits(Digits)),
|
||||||
|
("N", " N ", CounterFormat::Digits(Digits)),
|
||||||
|
("n", " n ", CounterFormat::Digits(Digits)),
|
||||||
|
("A", " A ", CounterFormat::AnsiUpper(AnsiUpper)),
|
||||||
|
("a", " a ", CounterFormat::AnsiLower(AnsiLower)),
|
||||||
|
("R", " R ", CounterFormat::RomanUpper(RomanUpper)),
|
||||||
|
("r", " r ", CounterFormat::RomanLower(RomanLower)),
|
||||||
|
("C", " C ", CounterFormat::CyrillicUpper(CyrillicUpper)),
|
||||||
|
("c", " c ", CounterFormat::CyrillicLower(CyrillicLower)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (without_space, with_space, expected) in formats.iter() {
|
||||||
|
let result = builder.try_set_format(without_space, 0..1, without_space);
|
||||||
|
assert_eq!(result.unwrap().format(), *expected,);
|
||||||
|
let result = builder.try_set_format(with_space, 0..5, with_space);
|
||||||
|
assert_eq!(result.unwrap().format(), *expected,);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = builder.try_set_format("", 0..0, "");
|
||||||
|
let error = result.unwrap_err();
|
||||||
|
assert_eq!(error.reason, "Empty counter kind");
|
||||||
|
assert_eq!(error.span, 0..0);
|
||||||
|
assert_eq!(error.expected, Some("one of D, d, N, n, A, a, R, r, C, c"));
|
||||||
|
assert_eq!(error.found, None);
|
||||||
|
|
||||||
|
let result =
|
||||||
|
CounterBuilder::new().try_set_format(" Ü-Wagen as examplé ", 2..27, " Ü-Wagen as examplé ");
|
||||||
|
let error = result.unwrap_err();
|
||||||
|
assert_eq!(error.reason, "Unexpected counter kind");
|
||||||
|
assert_eq!(error.span, 4..25);
|
||||||
|
assert_eq!(error.expected, Some("one of D, d, N, n, A, a, R, r, C, c"));
|
||||||
|
assert_eq!(error.found, Some("Ü-Wagen as examplé"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_set_start() {
|
||||||
|
let builder = CounterBuilder::new();
|
||||||
|
let result = builder.try_set_start("n,5", Some((2..3, "5"))).unwrap();
|
||||||
|
assert_eq!(result.start(), Some(5));
|
||||||
|
|
||||||
|
let formats = [
|
||||||
|
("D", "25", 25),
|
||||||
|
("d", "25", 25),
|
||||||
|
("N", "25", 25),
|
||||||
|
("n", "25", 25),
|
||||||
|
("A", "AB", 28),
|
||||||
|
("a", "ab", 28),
|
||||||
|
("R", "IV", 4),
|
||||||
|
("r", "iv", 4),
|
||||||
|
("C", "АБ", 30),
|
||||||
|
("c", "аб", 30),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (format, start, expected) in formats.iter() {
|
||||||
|
let builder = builder.try_set_format(format, 0..1, format).unwrap();
|
||||||
|
let result = builder.try_set_start(format, Some((2..4, start))).unwrap();
|
||||||
|
assert_eq!(result.start(), Some(*expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = builder.try_set_start("_", Some((2..3, "_"))).unwrap();
|
||||||
|
assert_eq!(result.start(), None);
|
||||||
|
|
||||||
|
let result = builder.try_set_start(" 5 ", Some((0..5, " 5 "))).unwrap();
|
||||||
|
assert_eq!(result.start(), Some(5));
|
||||||
|
|
||||||
|
let result =
|
||||||
|
builder.try_set_start(" Ü-Wagen as examplé ", Some((2..27, " Ü-Wagen as examplé ")));
|
||||||
|
let error = result.unwrap_err();
|
||||||
|
assert_eq!(error.span, 4..25);
|
||||||
|
assert_eq!(error.expected, Some("digit"));
|
||||||
|
assert_eq!(error.found, Some("Ü-Wagen as examplé"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_set_step() {
|
||||||
|
let builder = CounterBuilder::new();
|
||||||
|
let result = builder.try_set_step("5", Some((2..3, "5"))).unwrap();
|
||||||
|
assert_eq!(result.step(), Some(5));
|
||||||
|
|
||||||
|
let result = builder.try_set_step("_", Some((2..3, "_"))).unwrap();
|
||||||
|
assert_eq!(result.step(), None);
|
||||||
|
|
||||||
|
let result = builder.try_set_step(" 5 ", Some((0..5, " 5 "))).unwrap();
|
||||||
|
assert_eq!(result.step(), Some(5));
|
||||||
|
|
||||||
|
let result =
|
||||||
|
builder.try_set_step(" Ü-Wagen as examplé ", Some((2..27, " Ü-Wagen as examplé ")));
|
||||||
|
let error = result.unwrap_err();
|
||||||
|
assert_eq!(error.span, 4..25);
|
||||||
|
assert_eq!(error.expected, Some("digit"));
|
||||||
|
assert_eq!(error.found, Some("Ü-Wagen as examplé"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_set_width() {
|
||||||
|
let builder = CounterBuilder::new();
|
||||||
|
let result = builder.try_set_width("5", Some((2..3, "5"))).unwrap();
|
||||||
|
assert_eq!(result.width(), Some(5));
|
||||||
|
|
||||||
|
let result = builder.try_set_width("_", Some((2..3, "_"))).unwrap();
|
||||||
|
assert_eq!(result.width(), None);
|
||||||
|
|
||||||
|
let result = builder.try_set_width(" 5 ", Some((0..5, " 5 "))).unwrap();
|
||||||
|
assert_eq!(result.width(), Some(5));
|
||||||
|
|
||||||
|
let result =
|
||||||
|
builder.try_set_width(" Ü-Wagen as examplé ", Some((2..27, " Ü-Wagen as examplé ")));
|
||||||
|
let error = result.unwrap_err();
|
||||||
|
assert_eq!(error.span, 4..25);
|
||||||
|
assert_eq!(error.expected, Some("digit"));
|
||||||
|
assert_eq!(error.found, Some("Ü-Wagen as examplé"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_with_all_parameters() {
|
||||||
|
let builder = CounterBuilder::new()
|
||||||
|
.try_set_format("N,10,2,3", 0..1, "N")
|
||||||
|
.unwrap()
|
||||||
|
.try_set_start("N,10,2,3", Some((2..4, "10")))
|
||||||
|
.unwrap()
|
||||||
|
.try_set_step("N,10,2,3", Some((5..6, "2")))
|
||||||
|
.unwrap()
|
||||||
|
.try_set_width("N,10,2,3", Some((7..8, "3")))
|
||||||
|
.unwrap();
|
||||||
|
let counter = builder.build();
|
||||||
|
let mut buf = String::new();
|
||||||
|
counter.write_value(&mut buf).unwrap();
|
||||||
|
assert_eq!(buf, "010"); // Digits format, width 3
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_template_parse_no_counters() {
|
||||||
|
match Template::parse("plain text without counters") {
|
||||||
|
Err(TemplateError::NotCounter) => {}
|
||||||
|
_ => panic!("Expected NotCounter error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_template_parse_counters() {
|
||||||
|
use super::{CounterFormat as CF, TemplatePart as TP};
|
||||||
|
|
||||||
|
// test includes escaped counters %%{R,3,4,5}
|
||||||
|
let inputs: [(&str, CF, CF, CF, CF, CF, Option<u32>, Option<u32>, Option<usize>); 8] = [
|
||||||
|
(
|
||||||
|
"Ü-%%{R,3,4,5}_%{D}_examplé_%{d}_你好_%{N}_слово_%{n}_word_%{A}.txt",
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::AnsiUpper(AnsiUpper),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Ü-%%{R,3,4,5}_%{a}_examplé_%{R}_你好_%{r}_слово_%{C}_word_%{c}.txt",
|
||||||
|
CF::AnsiLower(AnsiLower),
|
||||||
|
CF::RomanUpper(RomanUpper),
|
||||||
|
CF::RomanLower(RomanLower),
|
||||||
|
CF::CyrillicUpper(CyrillicUpper),
|
||||||
|
CF::CyrillicLower(CyrillicLower),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Ü-%%{R,3,4,5}_%{D,3}_examplé_%{d,3}_你好_%{N,3}_слово_%{n,3}_word_%{A,3}.txt",
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::AnsiUpper(AnsiUpper),
|
||||||
|
Some(3),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Ü-%%{R,3,4,5}_%{a,c}_examplé_%{R,3}_你好_%{r,iii}_слово_%{C,3}_word_%{c,в}.txt",
|
||||||
|
CF::AnsiLower(AnsiLower),
|
||||||
|
CF::RomanUpper(RomanUpper),
|
||||||
|
CF::RomanLower(RomanLower),
|
||||||
|
CF::CyrillicUpper(CyrillicUpper),
|
||||||
|
CF::CyrillicLower(CyrillicLower),
|
||||||
|
Some(3),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Ü-%%{R,3,4,5}_%{D,3,4}_examplé_%{d,3,4}_你好_%{N,3,4}_слово_%{n,3,4}_word_%{A,3,4}.txt",
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::AnsiUpper(AnsiUpper),
|
||||||
|
Some(3),
|
||||||
|
Some(4),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Ü-%%{R,3,4,5}_%{a,c,14}_examplé_%{R,3,14}_你好_%{r,iii,14}_слово_%{C,3,14}_word_%{c,в,14}.txt",
|
||||||
|
CF::AnsiLower(AnsiLower),
|
||||||
|
CF::RomanUpper(RomanUpper),
|
||||||
|
CF::RomanLower(RomanLower),
|
||||||
|
CF::CyrillicUpper(CyrillicUpper),
|
||||||
|
CF::CyrillicLower(CyrillicLower),
|
||||||
|
Some(3),
|
||||||
|
Some(14),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Ü-%%{R,3,4,5}_%{D,3,4,55}_examplé_%{d,3,4,55}_你好_%{N,3,4,55}_слово_%{n,3,4,55}_word_%{A,3,4,55}.txt",
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::Digits(Digits),
|
||||||
|
CF::AnsiUpper(AnsiUpper),
|
||||||
|
Some(3),
|
||||||
|
Some(4),
|
||||||
|
Some(55),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Ü-%%{R,3,4,5}_%{a,c,14,6}_examplé_%{R,3,14,6}_你好_%{r,iii,14,6}_слово_%{C,3,14,6}_word_%{c,в,14,6}.txt",
|
||||||
|
CF::AnsiLower(AnsiLower),
|
||||||
|
CF::RomanUpper(RomanUpper),
|
||||||
|
CF::RomanLower(RomanLower),
|
||||||
|
CF::CyrillicUpper(CyrillicUpper),
|
||||||
|
CF::CyrillicLower(CyrillicLower),
|
||||||
|
Some(3),
|
||||||
|
Some(14),
|
||||||
|
Some(6),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (idx, &(input, c1, c2, c3, c4, c5, start, step, width)) in inputs.iter().enumerate() {
|
||||||
|
let parsed = Template::parse(input).expect("Should parse a single counter");
|
||||||
|
assert_eq!(parsed.counter_count(), 5);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parsed.parts(),
|
||||||
|
&[
|
||||||
|
TP::Text("Ü-"),
|
||||||
|
TP::Text("%{"),
|
||||||
|
TP::Text("R,3,4,5}_"),
|
||||||
|
TP::CounterBuilder(CounterBuilder { format: c1, start, step, width }),
|
||||||
|
TP::Text("_example\u{301}_"),
|
||||||
|
TP::CounterBuilder(CounterBuilder { format: c2, start, step, width }),
|
||||||
|
TP::Text("_你好_"),
|
||||||
|
TP::CounterBuilder(CounterBuilder { format: c3, start, step, width }),
|
||||||
|
TP::Text("_слово_"),
|
||||||
|
TP::CounterBuilder(CounterBuilder { format: c4, start, step, width }),
|
||||||
|
TP::Text("_word_"),
|
||||||
|
TP::CounterBuilder(CounterBuilder { format: c5, start, step, width }),
|
||||||
|
TP::Text(".txt")
|
||||||
|
],
|
||||||
|
"Failed to pass {} index",
|
||||||
|
idx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_template_parse_unclosed_delimiter() {
|
||||||
|
let input = "file_%{N.txt";
|
||||||
|
match Template::parse(input) {
|
||||||
|
Err(TemplateError::Parse(parse_err)) => {
|
||||||
|
assert_eq!(parse_err.reason, "Unclosed delimiter");
|
||||||
|
assert_eq!(parse_err.expected, Some("}"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected 'unclosed delimiter'."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_template_parse_extra_commas() {
|
||||||
|
let input = "some %{N,2,2,2,} text";
|
||||||
|
let result = Template::parse(input);
|
||||||
|
match result {
|
||||||
|
Err(TemplateError::Parse(parse_err)) => {
|
||||||
|
assert_eq!(
|
||||||
|
parse_err,
|
||||||
|
ParseError {
|
||||||
|
input: "some %{N,2,2,2,} text",
|
||||||
|
span: 14..15,
|
||||||
|
reason: "Extra arguments",
|
||||||
|
expected: Some("no additional arguments"),
|
||||||
|
found: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("Expected TemplateError::Parse"),
|
||||||
|
Err(_) => panic!("Expected TemplateError::Parse"),
|
||||||
|
}
|
||||||
|
}
|
||||||
232
yazi-actor/src/mgr/bulk_rename/name_generator/mod.rs
Normal file
232
yazi-actor/src/mgr/bulk_rename/name_generator/mod.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
//! This module provides functionality to generate file paths from input strings
|
||||||
|
//! containing counters and template variables. It parses lines of text and
|
||||||
|
//! produces either fixed filenames or filenames based on templates with
|
||||||
|
//! dynamically updated counters.
|
||||||
|
|
||||||
|
use std::{error::Error, fmt, fmt::Write, ops::Range};
|
||||||
|
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
use super::{Tuple, counters::Counter, filename_template::{ParseError, ParsedLine, TemplatePart}};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
/// Generates a sequence of file paths from an input lines of text.
|
||||||
|
///
|
||||||
|
/// Each line is parsed into either a fixed filename or a template containing
|
||||||
|
/// counters. If the line contains a template, counters are created and
|
||||||
|
/// dynamically updated across lines, ensuring consistent numbering and
|
||||||
|
/// formatting.
|
||||||
|
///
|
||||||
|
/// For details on counter syntax and template parsing, see
|
||||||
|
/// [`super::filename_template::Template::parse`].
|
||||||
|
///
|
||||||
|
/// If any line contains counters, the number of counters must remain consistent
|
||||||
|
/// across all lines. If there's a mismatch in the expected and actual number of
|
||||||
|
/// counters in any line, the function returns error.
|
||||||
|
///
|
||||||
|
/// # Error Handling
|
||||||
|
///
|
||||||
|
/// Instead of stopping execution at the first encountered error, the function
|
||||||
|
/// collects all errors, allowing the caller to see every problematic line at
|
||||||
|
/// once.
|
||||||
|
///
|
||||||
|
/// # Flexibility
|
||||||
|
///
|
||||||
|
/// While the number of counters per line must be consistent, individual counter
|
||||||
|
/// parameters (such as format, start, step, and width) may vary line by line.
|
||||||
|
/// Counters update their values accordingly based on each line’s
|
||||||
|
/// specifications.
|
||||||
|
pub fn generate_names<'a, T>(lines: &mut T) -> Result<Vec<Tuple>, NameGenerationErrors<'a>>
|
||||||
|
where
|
||||||
|
T: Iterator<Item = &'a str>,
|
||||||
|
{
|
||||||
|
let mut results = Vec::<Tuple>::new();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
let mut counters = Vec::new();
|
||||||
|
|
||||||
|
for (idx, line) in lines.enumerate() {
|
||||||
|
match ParsedLine::try_from(line) {
|
||||||
|
Ok(ParsedLine::Fixed(literal)) => {
|
||||||
|
results.push(Tuple::new(idx, literal));
|
||||||
|
}
|
||||||
|
Ok(ParsedLine::Countable(template)) => {
|
||||||
|
if counters.is_empty() {
|
||||||
|
counters.extend(template.parts().iter().filter_map(|part| match part {
|
||||||
|
TemplatePart::Text(_) => None,
|
||||||
|
TemplatePart::CounterBuilder(builder) => Some(builder.build()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if counters.len() != template.counter_count() {
|
||||||
|
errors.push(NameGenError::MismatchCounters {
|
||||||
|
expected: counters.len(),
|
||||||
|
got: template.counter_count(),
|
||||||
|
line_number: idx + 1,
|
||||||
|
content: line,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut counter_idx = 0;
|
||||||
|
|
||||||
|
for part in template.parts() {
|
||||||
|
match part {
|
||||||
|
TemplatePart::Text(text) => {
|
||||||
|
out.push_str(text);
|
||||||
|
}
|
||||||
|
TemplatePart::CounterBuilder(builder) => {
|
||||||
|
let counter = &mut counters[counter_idx];
|
||||||
|
counter.update_from(*builder);
|
||||||
|
|
||||||
|
let _ = counter.write_value(&mut out);
|
||||||
|
counter.advance();
|
||||||
|
|
||||||
|
counter_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(Tuple::new(idx, out));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
errors.push(NameGenError::ParseError { line_number: idx + 1, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.is_empty() { Ok(results) } else { Err(NameGenerationErrors { errors }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents errors that can occur during filename generation.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum NameGenError<'a> {
|
||||||
|
/// Error parsing a line into a valid counter template.
|
||||||
|
ParseError { line_number: usize, error: ParseError<'a> },
|
||||||
|
|
||||||
|
/// Error indicating mismatch between the expected and actual
|
||||||
|
/// number of counters at some line
|
||||||
|
MismatchCounters {
|
||||||
|
expected: usize,
|
||||||
|
got: usize,
|
||||||
|
line_number: usize,
|
||||||
|
content: &'a str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for NameGenError<'_> {
|
||||||
|
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
// Calculates the number of digits in a given line number (for formatting
|
||||||
|
// alignment).
|
||||||
|
fn print_width(mut n: usize) -> usize {
|
||||||
|
let mut width = 1;
|
||||||
|
while n >= 10 {
|
||||||
|
n /= 10;
|
||||||
|
width += 1;
|
||||||
|
}
|
||||||
|
width
|
||||||
|
}
|
||||||
|
|
||||||
|
match self {
|
||||||
|
NameGenError::ParseError { line_number, error } => {
|
||||||
|
let Range { start, end } = error.span;
|
||||||
|
|
||||||
|
// Calculate the width needed for the line number.
|
||||||
|
let line_num_width = print_width(*line_number);
|
||||||
|
|
||||||
|
// Calculate the width needed to print the line number and
|
||||||
|
// separator (e.g., "182| ").
|
||||||
|
// let available = term_width.saturating_sub(line_num_width as u16 + 2) as
|
||||||
|
// usize;
|
||||||
|
|
||||||
|
// Calculate the width of the string before the error start.
|
||||||
|
let input_len_left = UnicodeWidthStr::width(&error.input[..start]);
|
||||||
|
// Calculate the width of the error span, ensuring at least 1 character.
|
||||||
|
let input_len_span = UnicodeWidthStr::width(&error.input[start..end]).max(1);
|
||||||
|
|
||||||
|
// Constructs a hint string indicating expected and found values, if applicable.
|
||||||
|
let mut hint = String::new();
|
||||||
|
|
||||||
|
if let Some(exp) = &error.expected {
|
||||||
|
let _ = write!(hint, " Expected: '{exp}'");
|
||||||
|
}
|
||||||
|
if let Some(fnd) = &error.found {
|
||||||
|
let _ = write!(hint, ", found: '{fnd}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the error header
|
||||||
|
write!(fmt, "Error: {}", error.reason)?;
|
||||||
|
|
||||||
|
// Write a blank line with alignment for the line number.
|
||||||
|
writeln!(fmt, "\n{:>offset$}|", "", offset = line_num_width)?;
|
||||||
|
|
||||||
|
// Write the line number and input, with optional ellipses.
|
||||||
|
write!(fmt, "{line_number}| ")?;
|
||||||
|
|
||||||
|
write!(fmt, "{}", error.input)?;
|
||||||
|
|
||||||
|
// Write the caret line indicating the error span and the hint.
|
||||||
|
writeln!(
|
||||||
|
fmt,
|
||||||
|
"\n{:>num_offset$}| {:>offset$}{:^>length$}{}\n",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
hint,
|
||||||
|
num_offset = line_num_width,
|
||||||
|
offset = input_len_left,
|
||||||
|
length = input_len_span
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NameGenError::MismatchCounters { expected, got, line_number, content } => {
|
||||||
|
// Calculate the width needed for the line number.
|
||||||
|
let line_num_width = print_width(*line_number);
|
||||||
|
|
||||||
|
// Calculate the width of the content.
|
||||||
|
let input_len_span = UnicodeWidthStr::width(*content).max(1);
|
||||||
|
|
||||||
|
let hint = format!(" Expected {expected} counters, but got {got}");
|
||||||
|
|
||||||
|
// Print the error header
|
||||||
|
write!(fmt, "Error: Mismatch counter numbers")?;
|
||||||
|
|
||||||
|
// Write a blank line with alignment.
|
||||||
|
writeln!(fmt, "\n{:>offset$}|", "", offset = line_num_width)?;
|
||||||
|
|
||||||
|
// Write the line number and content.
|
||||||
|
writeln!(fmt, "{line_number}| {content}")?;
|
||||||
|
|
||||||
|
// Write the caret line spanning the entire content and the hint.
|
||||||
|
writeln!(
|
||||||
|
fmt,
|
||||||
|
"{:>num_offset$}| {:^>length$}{}\n",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
hint,
|
||||||
|
num_offset = line_num_width,
|
||||||
|
length = input_len_span
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for NameGenError<'_> {}
|
||||||
|
|
||||||
|
/// Represents a collection of errors that occurred during filename generation.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct NameGenerationErrors<'a> {
|
||||||
|
pub errors: Vec<NameGenError<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> fmt::Display for NameGenerationErrors<'a> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.errors.iter().try_for_each(|e| write!(f, "{}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Error for NameGenerationErrors<'a> {}
|
||||||
133
yazi-actor/src/mgr/bulk_rename/name_generator/tests.rs
Normal file
133
yazi-actor/src/mgr/bulk_rename/name_generator/tests.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Helper to quickly compare Ok(Vec<Tuple>)
|
||||||
|
fn assert_ok_paths(result: Result<Vec<Tuple>, NameGenerationErrors>, expected: &[&str]) {
|
||||||
|
match result {
|
||||||
|
Ok(paths) => {
|
||||||
|
let actual: Vec<_> = paths.iter().map(|p| p.to_string_lossy()).collect();
|
||||||
|
let expected: Vec<_> = expected.iter().copied().map(String::from).collect();
|
||||||
|
assert_eq!(actual, expected, "Expected {:?}, got {:?}", expected, actual);
|
||||||
|
}
|
||||||
|
Err(errs) => panic!("Expected Ok(...), got Err({:?})", errs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_names_no_counters() {
|
||||||
|
// All lines are just plain filenames (no counters)
|
||||||
|
let mut input = "file1.txt\nfile2.txt\nanother_file\n".lines();
|
||||||
|
let result = generate_names(&mut input);
|
||||||
|
// Should succeed, returning the same lines as PathBuf
|
||||||
|
assert_ok_paths(result, &["file1.txt", "file2.txt", "another_file"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_names() {
|
||||||
|
let input = [
|
||||||
|
// Start = 1, Step = 1, Width = 1
|
||||||
|
"file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 1
|
||||||
|
"file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 1
|
||||||
|
// Start = 5, Step = 1, Width = 1
|
||||||
|
"file_%{D,5}_%{d,5}_%{N,5}_%{n,5}_%{A,5}_%{a,5}_%{R,5}_%{r,5}_%{C,5}_%{c,5}.txt", // print 5
|
||||||
|
"file_%{D }_%{d }_%{N }_%{n }_%{A }_%{a }_%{R }_%{r }_%{C }_%{c }.txt", // print 6
|
||||||
|
// Start = 5 (two times), Step = 1, Width = 1
|
||||||
|
"file_%{D,5}_%{d,5}_%{N,5}_%{n,5}_%{A,5}_%{a,5}_%{R,5}_%{r,5}_%{C,5}_%{c,5}.txt", // print 5
|
||||||
|
"file_%{D,5}_%{d,5}_%{N,5}_%{n,5}_%{A,5}_%{a,5}_%{R,5}_%{r,5}_%{C,5}_%{c,5}.txt", // print 5 (again)
|
||||||
|
// Start = 5, Step = 3, Width = 1
|
||||||
|
"file_%{D,_,3}_%{d,_,3}_%{N,_,3}_%{n,_,3}_%{A,_,3}_%{a,_,3}_%{R,_,3}_%{r,_,3}_%{C,_,3}_%{c,_,3}.txt", // print 6
|
||||||
|
"file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 9
|
||||||
|
// Start = 5, Step = 3, Width = 3
|
||||||
|
"file_%{D,_,_,3}_%{d,_,_,3}_%{N,_,_,3}_%{n,_,_,3}_%{A,_,_,3}_%{a,_,_,3}_%{R,_,_,3}_%{r,_,_,3}_%{C,_,_,3}_%{c,_,_,3}.txt", // print 012
|
||||||
|
"file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 015
|
||||||
|
// Change counter formats, Start = 5, Step = 3, Width = 3
|
||||||
|
"file_%{A}_%{R}_%{C}_%{N}_%{a}_%{r}_%{c}_%{n}_%{D}_%{d}.txt", // print 018
|
||||||
|
]
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
let result = generate_names(&mut input.lines());
|
||||||
|
assert_ok_paths(result, &[
|
||||||
|
"file_1_1_1_1_A_a_I_i_А_а.txt",
|
||||||
|
"file_2_2_2_2_B_b_II_ii_Б_б.txt",
|
||||||
|
"file_5_5_5_5_E_e_V_v_Д_д.txt",
|
||||||
|
"file_6_6_6_6_F_f_VI_vi_Е_е.txt",
|
||||||
|
"file_5_5_5_5_E_e_V_v_Д_д.txt",
|
||||||
|
"file_5_5_5_5_E_e_V_v_Д_д.txt",
|
||||||
|
"file_6_6_6_6_F_f_VI_vi_Е_е.txt",
|
||||||
|
"file_9_9_9_9_I_i_IX_ix_И_и.txt",
|
||||||
|
"file_012_012_012_012_00L_00l_XII_xii_00М_00м.txt",
|
||||||
|
"file_015_015_015_015_00O_00o_0XV_0xv_00П_00п.txt",
|
||||||
|
"file_00R_XVIII_00Т_018_00r_xviii_00т_018_018_018.txt",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_names_mismatch_counters() {
|
||||||
|
// First line has 2 counters, second line has 1
|
||||||
|
let input = "\
|
||||||
|
file_%{n}_%{a}.txt\n\
|
||||||
|
file_%{n}.txt\
|
||||||
|
";
|
||||||
|
let result = generate_names(&mut input.lines()).unwrap_err();
|
||||||
|
// Should produce PathGenError::MismatchCounters
|
||||||
|
assert_eq!(result.errors, &[NameGenError::MismatchCounters {
|
||||||
|
expected: 2,
|
||||||
|
got: 1,
|
||||||
|
line_number: 2,
|
||||||
|
content: "file_%{n}.txt",
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_names_parse_errors() {
|
||||||
|
let input = "\
|
||||||
|
Ü-Wagen examplé_слово_%{???}.txt\n\
|
||||||
|
Ü-Wagen examplé_слово_%{n,???}.txt\n\
|
||||||
|
Ü-Wagen examplé_слово_%{n,1,???}.txt\n\
|
||||||
|
Ü-Wagen examplé_слово_%{n,1,1,???}.txt\n\
|
||||||
|
Ü-Wagen examplé_слово_%{n,1,1,1,???}.txt\n\
|
||||||
|
Ü-Wagen examplé_слово_%{n,1,1,1,}.txt\n\
|
||||||
|
Ü-Wagen examplé_слово_%{n}.txt\n\
|
||||||
|
Ü-Wagen examplé_слово_%{n}_%{n}.txt
|
||||||
|
";
|
||||||
|
let output = generate_names(&mut input.lines()).unwrap_err().to_string();
|
||||||
|
|
||||||
|
let expected = "\
|
||||||
|
Error: Unexpected counter kind
|
||||||
|
|
|
||||||
|
1| Ü-Wagen examplé_слово_%{???}.txt
|
||||||
|
| ^^^ Expected: 'one of D, d, N, n, A, a, R, r, C, c', found: '???'
|
||||||
|
|
||||||
|
Error: Invalid digit found in string
|
||||||
|
|
|
||||||
|
2| Ü-Wagen examplé_слово_%{n,???}.txt
|
||||||
|
| ^^^ Expected: 'digit', found: '???'
|
||||||
|
|
||||||
|
Error: Invalid digit found in string
|
||||||
|
|
|
||||||
|
3| Ü-Wagen examplé_слово_%{n,1,???}.txt
|
||||||
|
| ^^^ Expected: 'digit', found: '???'
|
||||||
|
|
||||||
|
Error: Invalid digit found in string
|
||||||
|
|
|
||||||
|
4| Ü-Wagen examplé_слово_%{n,1,1,???}.txt
|
||||||
|
| ^^^ Expected: 'digit', found: '???'
|
||||||
|
|
||||||
|
Error: Extra arguments
|
||||||
|
|
|
||||||
|
5| Ü-Wagen examplé_слово_%{n,1,1,1,???}.txt
|
||||||
|
| ^^^^ Expected: 'no additional arguments'
|
||||||
|
|
||||||
|
Error: Extra arguments
|
||||||
|
|
|
||||||
|
6| Ü-Wagen examplé_слово_%{n,1,1,1,}.txt
|
||||||
|
| ^ Expected: 'no additional arguments'
|
||||||
|
|
||||||
|
Error: Mismatch counter numbers
|
||||||
|
|
|
||||||
|
8| Ü-Wagen examplé_слово_%{n}_%{n}.txt
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Expected 1 counters, but got 2
|
||||||
|
|
||||||
|
";
|
||||||
|
|
||||||
|
assert_eq!(output, expected);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue