Add counters to bulk rename function

This commit is contained in:
Alisher Galiev 2025-03-24 15:13:53 +05:00
parent e9742cb809
commit f64c0d9694
14 changed files with 2291 additions and 19 deletions

1
Cargo.lock generated
View file

@ -4595,6 +4595,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tracing",
"unicode-width 0.2.0",
"yazi-binding",
"yazi-boot",
"yazi-config",

View file

@ -30,16 +30,17 @@ yazi-watcher = { path = "../yazi-watcher", version = "25.9.15" }
yazi-widgets = { path = "../yazi-widgets", version = "25.9.15" }
# External dependencies
anyhow = { workspace = true }
crossterm = { workspace = true }
futures = { workspace = true }
hashbrown = { workspace = true }
mlua = { workspace = true }
paste = { workspace = true }
scopeguard = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }
crossterm = { workspace = true }
futures = { workspace = true }
hashbrown = { workspace = true }
mlua = { workspace = true }
paste = { workspace = true }
scopeguard = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
unicode-width = { workspace = true }
[target."cfg(unix)".dependencies]
libc = { workspace = true }

View file

@ -19,6 +19,12 @@ use crate::{Actor, Ctx};
pub struct BulkRename;
mod counters;
mod filename_template;
mod name_generator;
use name_generator::generate_names;
impl Actor for BulkRename {
type Options = VoidOpt;
@ -60,15 +66,8 @@ impl Actor for BulkRename {
defer!(AppProxy::resume());
AppProxy::stop().await;
let new: Vec<_> = Local
.read_to_string(&tmp)
.await?
.lines()
.take(old.len())
.enumerate()
.map(|(i, s)| Tuple::new(i, s))
.collect();
let new_names = Local.read_to_string(&tmp).await?;
let new = Self::parse_new_names(&new_names, old.len()).await?;
Self::r#do(root, old, new, selected).await
});
succ!();
@ -76,6 +75,29 @@ impl Actor for 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(
root: usize,
old: Vec<Tuple>,

View 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)
}

View 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, 'ё' | 'й' | 'ъ' | 'ы' | 'ь')
})
}
}

View 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()
}
}

View 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(())
}

View 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;
}
}

View 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))
}

View 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));
}
}

View 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()
}
}

View 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"),
}
}

View 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 lines
/// 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> {}

View 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);
}