Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit cbedc43

Browse files
committedMar 25, 2025·
Add counters to bulk rename function
1 parent 31fccd4 commit cbedc43

File tree

12 files changed

+2380
-3
lines changed

12 files changed

+2380
-3
lines changed
 

‎yazi-core/src/mgr/commands/bulk_rename.rs

+43-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::{borrow::Cow, collections::HashMap, ffi::{OsStr, OsString}, io::{Read, Write}, path::PathBuf};
22

33
use anyhow::{Result, anyhow};
4-
use crossterm::{execute, style::Print};
4+
use crossterm::{execute, style::Print, terminal};
55
use scopeguard::defer;
66
use tokio::{fs::{self, OpenOptions}, io::AsyncWriteExt};
77
use yazi_config::YAZI;
@@ -12,6 +12,12 @@ use yazi_shared::{terminal_clear, tty::TTY, url::Url};
1212

1313
use crate::mgr::Mgr;
1414

15+
mod counters;
16+
mod filename_template;
17+
mod name_generator;
18+
19+
use name_generator::generate_names;
20+
1521
impl Mgr {
1622
pub(super) fn bulk_rename(&self) {
1723
let Some(opener) = YAZI.opener.block(YAZI.open.all("bulk-rename.txt", "text/plain")) else {
@@ -46,12 +52,46 @@ impl Mgr {
4652
defer!(AppProxy::resume());
4753
AppProxy::stop().await;
4854

49-
let new: Vec<_> =
50-
fs::read_to_string(&tmp).await?.lines().take(old.len()).map(PathBuf::from).collect();
55+
let new_names = fs::read_to_string(&tmp).await?;
56+
let new = Self::parse_new_names(&new_names, old.len()).await?;
5157
Self::bulk_rename_do(root, old, new).await
5258
});
5359
}
5460

61+
/// Reads a number of lines from a string, attempting to parse them as either
62+
/// fixed filenames or counter-based templates.
63+
///
64+
/// The number of expected lines must match `expected_count`.
65+
/// If parsing fails, displays all errors to the user and waits for ENTER
66+
/// before returning an error.
67+
async fn parse_new_names(new_names: &str, expected_count: usize) -> Result<Vec<PathBuf>> {
68+
match generate_names(&mut new_names.lines().take(expected_count)) {
69+
Ok(paths) => Ok(paths),
70+
Err(errors) => {
71+
let (width, _) = terminal::size().unwrap_or((80, 120));
72+
let mut buffer = String::new();
73+
errors.iter().for_each(|error| {
74+
let _ = error.write_to(&mut buffer, width);
75+
});
76+
77+
// Show all parse errors in TTY, then return an error
78+
terminal_clear(TTY.writer())?;
79+
{
80+
let mut w = TTY.lockout();
81+
writeln!(w, "Errors encountered while parsing rename lines:")?;
82+
writeln!(w, "{buffer}")?;
83+
writeln!(w, "\nPress ENTER to exit")?;
84+
w.flush()?;
85+
}
86+
// Wait for user input
87+
TTY.reader().read_exact(&mut [0])?;
88+
89+
// Return an error to skip further rename
90+
Err(anyhow::anyhow!("Parsing errors in rename lines"))
91+
}
92+
}
93+
}
94+
5595
async fn bulk_rename_do(root: PathBuf, old: Vec<PathBuf>, new: Vec<PathBuf>) -> Result<()> {
5696
terminal_clear(TTY.writer())?;
5797
if old.len() != new.len() {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//! This module provides functionality for managing ANSI letter counters for both
2+
//! uppercase and lowercase letters, following Excel's alphabetic counter style.
3+
4+
use super::{CounterFormatter, LOWERCASE, UPPERCASE, write_number_as_letters_gen};
5+
use std::fmt;
6+
7+
/// A helper structure for generating uppercase ANSI letters (e.g., A, B, ..., AA, AB).
8+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9+
pub struct AnsiUpper;
10+
11+
/// A helper structure for generating lowercase ANSI letters (e.g., a, b, ..., aa, ab).
12+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13+
pub struct AnsiLower;
14+
15+
impl_counter_formatter! { AnsiUpper, UPPERCASE }
16+
impl_counter_formatter! { AnsiLower, LOWERCASE }
17+
18+
/// Converts ANSI letters (e.g., "A", "Z", "AA") to their corresponding numeric values.
19+
/// The conversion follows Excel's alphabetic counter rules: 'A' = 1, 'B' = 2, ...,
20+
/// 'Z' = 26, 'AA' = 27, etc.
21+
///
22+
/// The `UPPERCASE` constant determines whether the string should be validated
23+
/// as uppercase or lowercase.
24+
///
25+
/// # Returns
26+
///
27+
/// Returns `Some(u32)` if conversion is successful; otherwise, returns `None`.
28+
#[inline]
29+
fn convert_letters_to_number<const UPPERCASE: bool>(value: &str) -> Option<u32> {
30+
if value.is_empty() {
31+
return None;
32+
}
33+
34+
if UPPERCASE {
35+
if !value.chars().all(|c| c.is_ascii_uppercase()) {
36+
return None;
37+
}
38+
} else if !value.chars().all(|c| c.is_ascii_lowercase()) {
39+
return None;
40+
}
41+
42+
let result = value.chars().rev().enumerate().fold(0_u32, |acc, (i, c)| {
43+
acc + ((c as u32) - (if UPPERCASE { 'A' } else { 'a' } as u32) + 1) * 26_u32.pow(i as u32)
44+
});
45+
46+
Some(result)
47+
}
48+
49+
/// Writes the numeric value as ANSI letters (e.g., 1 → "A", 27 → "AA") into the provided buffer.
50+
///
51+
/// # Arguments
52+
///
53+
/// * `num` - The numeric value to convert.
54+
/// * `width` - The minimum width of the generated string, padded with zeros if necessary.
55+
/// * `buf` - The buffer to write the resulting string into.
56+
#[inline]
57+
fn write_number_as_letters<const UPPERCASE: bool>(
58+
num: u32,
59+
width: usize,
60+
buf: &mut impl fmt::Write,
61+
) -> fmt::Result {
62+
let base = if UPPERCASE { b'A' } else { b'a' };
63+
write_number_as_letters_gen(num, width, 26, |r| (base + r as u8) as char, buf)
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//! This module provides functionality for managing Cyrillic letter counters for both
2+
//! uppercase and lowercase letters, following Excel's alphabetic counter style.
3+
4+
use super::{CounterFormatter, LOWERCASE, UPPERCASE, write_number_as_letters_gen};
5+
use std::fmt;
6+
7+
/// An array of uppercase Cyrillic letters used for indexing and mapping.
8+
/// This array includes all uppercase Cyrillic letters excluding 'Ё', 'Й', 'Ъ', 'Ы', 'Ь'.
9+
const UPPERCASE_CYRILLIC: [char; 28] = [
10+
'А', 'Б', 'В', 'Г', 'Д', 'Е', 'Ж', 'З', 'И', 'К', 'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У',
11+
'Ф', 'Х', 'Ц', 'Ч', 'Ш', 'Щ', 'Э', 'Ю', 'Я',
12+
];
13+
14+
/// An array of lowercase Cyrillic letters used for indexing and mapping.
15+
/// This array includes all lowercase Cyrillic letters excluding 'ё', 'й', 'ъ', 'ы', 'ь'.
16+
const LOWERCASE_CYRILLIC: [char; 28] = [
17+
'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у',
18+
'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'э', 'ю', 'я',
19+
];
20+
21+
/// A helper structure for generating uppercase Cyrillic letters (e.g., А, Б, В, ..., АА, АБ),
22+
/// while excluding 'Ё', 'Й', 'Ъ', 'Ы' and 'Ь'.
23+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24+
pub struct CyrillicUpper;
25+
26+
/// A helper structure for generating lowercase Cyrillic letters (e.g., а, б, в, ..., аа, аб),
27+
/// while excluding 'ё', 'й', 'ъ', 'ы' and 'ь'.
28+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29+
pub struct CyrillicLower;
30+
31+
impl_counter_formatter! { CyrillicUpper, UPPERCASE }
32+
impl_counter_formatter! { CyrillicLower, LOWERCASE }
33+
34+
/// Converts Cyrillic letters (e.g., "Б", "В", "БА") to their corresponding numeric values.
35+
/// The conversion follows Excel's alphabetic counter rules: 'А' = 1, 'Б' = 2, ...,
36+
/// 'Я' = 28, 'АА' = 29, etc.
37+
///
38+
/// The `UPPERCASE` constant determines whether the string should be validated
39+
/// as uppercase or lowercase.
40+
///
41+
/// # Returns
42+
///
43+
/// Returns `Some(u32)` if conversion is successful; otherwise, returns `None`.
44+
#[inline]
45+
fn convert_letters_to_number<const UPPERCASE: bool>(value: &str) -> Option<u32> {
46+
if invalid_string::<UPPERCASE>(value) {
47+
return None;
48+
}
49+
let lookup = if UPPERCASE { &UPPERCASE_CYRILLIC } else { &LOWERCASE_CYRILLIC };
50+
51+
let result = value.chars().rev().enumerate().fold(0_u32, |acc, (i, c)| {
52+
if let Some(index) = lookup.iter().position(|&x| x == c) {
53+
acc + (index as u32 + 1) * 28_u32.pow(i as u32)
54+
} else {
55+
acc
56+
}
57+
});
58+
Some(result)
59+
}
60+
61+
/// Writes the numeric value as Cyrillic letters (e.g., 1 → "А", 28 → "Я") into the provided buffer.
62+
///
63+
/// # Arguments
64+
///
65+
/// * `num` - The numeric value to convert.
66+
/// * `width` - The minimum width of the generated string, padded with zeros if necessary.
67+
/// * `buf` - The buffer to write the resulting string into.
68+
#[inline]
69+
fn write_number_as_letters<const UPPERCASE: bool>(
70+
num: u32,
71+
width: usize,
72+
buf: &mut impl fmt::Write,
73+
) -> fmt::Result {
74+
let lookup = if UPPERCASE { &UPPERCASE_CYRILLIC } else { &LOWERCASE_CYRILLIC };
75+
76+
write_number_as_letters_gen(num, width, 28, |remainder| lookup[remainder as usize], buf)
77+
}
78+
79+
/// Checks if a string is non-empty and consists only of valid uppercase or
80+
/// lowercase Cyrillic letters, excluding 'Ё', 'Й', 'Ъ', 'Ы', and 'Ь'
81+
/// ('ё', 'й', 'ъ', 'ы' and 'ь').
82+
///
83+
/// The `UPPERCASE` constant determines whether to check uppercase or lowercase letters.
84+
///
85+
/// # Returns
86+
///
87+
/// Returns `true` if the string is invalid; otherwise, returns `false`.
88+
#[inline]
89+
fn invalid_string<const UPPERCASE: bool>(str: &str) -> bool {
90+
if str.is_empty() {
91+
return true;
92+
}
93+
if UPPERCASE {
94+
!str.chars().all(|c| {
95+
// ('А'..='Я') == ('\u{0410}'..='\u{042F}')
96+
('\u{0410}'..='\u{042F}').contains(&c) && !matches!(c, 'Ё' | 'Й' | 'Ъ' | 'Ы' | 'Ь')
97+
})
98+
} else {
99+
!str.chars().all(|c| {
100+
// ('а'..='я') == ('\u{0430}'..='\u{044F}')
101+
('\u{0430}'..='\u{044F}').contains(&c) && !matches!(c, 'ё' | 'й' | 'ъ' | 'ы' | 'ь')
102+
})
103+
}
104+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//! This module provides functionality for managing Arabic numeral counters.
2+
3+
use super::CounterFormatter;
4+
use std::fmt;
5+
6+
/// A helper structure for generating numeric values (e.g., 1, 2, ..., 999 or 001, 002).
7+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8+
pub struct Digits;
9+
10+
impl CounterFormatter for Digits {
11+
/// Formats a value as a zero-padded string and writes it to a buffer.
12+
///
13+
/// # Arguments
14+
///
15+
/// * `value` - The numeric value to format.
16+
/// * `width` - The minimum width of the output string.
17+
/// * `buf` - A mutable reference to a buffer.
18+
#[inline]
19+
fn value_to_buffer(
20+
self,
21+
value: u32,
22+
width: usize,
23+
buf: &mut impl fmt::Write,
24+
) -> Result<(), fmt::Error> {
25+
write!(buf, "{value:0>width$}")
26+
}
27+
28+
/// Parses a zero-padded numeric string into a `u32` value.
29+
#[inline]
30+
fn string_to_value(self, value: &str) -> Option<u32> {
31+
value.parse().ok()
32+
}
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
use std::fmt;
2+
3+
/// This macro generates an implementation of CounterFormatter for a given
4+
/// counter helper type.
5+
///
6+
/// # Arguments
7+
///
8+
/// * `$type` - The target helper struct (e.g., `AnsiUpper`).
9+
/// * `$case` - A boolean constant determining whether the counter uppercase or lowercase.
10+
macro_rules! impl_counter_formatter {
11+
($type:ty, $case:expr) => {
12+
impl CounterFormatter for $type {
13+
#[inline]
14+
fn value_to_buffer(self, value: u32, width: usize, buf: &mut impl fmt::Write) -> fmt::Result {
15+
write_number_as_letters::<{ $case }>(value, width, buf)
16+
}
17+
18+
#[inline]
19+
fn string_to_value(self, value: &str) -> Option<u32> {
20+
convert_letters_to_number::<{ $case }>(value)
21+
}
22+
}
23+
};
24+
}
25+
26+
/// Converts a given numeric value into an alphabetic representation following a base-N numbering system,
27+
/// similar to Excel-style column labels (e.g., 1 → A, 2 → B, ..., 26 → Z, 27 → AA, etc.).
28+
///
29+
/// This function generalizes the process for different alphabets by allowing a customizable base (`alphabet_len`)
30+
/// and a transformation function (`convert_fn`) that maps remainder values to characters.
31+
///
32+
/// # Arguments
33+
///
34+
/// * `num` - The numeric value to be converted. Since alphabetic numbering systems start from 1
35+
/// (e.g., A = 1, B = 2), it should be non-zero value.
36+
///
37+
/// * `width` - The minimum width of the output string. If necessary, the result will be left-padded with '0'.
38+
///
39+
/// * `alphabet_len` - The base of the numbering system (e.g., 26 for Latin, 28 for Cyrillic, etc.).
40+
///
41+
/// * `convert_fn` - A closure that converts a remainder (`u32`) into a corresponding character.
42+
/// - The `remainder` represents the remainder of division by `alphabet_len` (i.e., `num % alphabet_len`).
43+
/// - The closure should map this remainder to a specific character in the corresponding alphabet
44+
/// (e.g., `b'A' + remainder as u8`).
45+
///
46+
/// * `buf` - A mutable reference to a `fmt::Write` buffer where the result is written.
47+
#[inline]
48+
pub(super) fn write_number_as_letters_gen(
49+
mut num: u32,
50+
width: usize,
51+
alphabet_len: u32,
52+
mut convert_fn: impl FnMut(u32) -> char,
53+
buf: &mut impl fmt::Write,
54+
) -> fmt::Result {
55+
if num == 0 {
56+
return Ok(());
57+
}
58+
59+
let mut stack_buf = ['0'; 10];
60+
let mut written_len = 0;
61+
62+
for char in &mut stack_buf {
63+
if num == 0 {
64+
break;
65+
}
66+
let remainder = (num - 1) % alphabet_len;
67+
*char = convert_fn(remainder);
68+
num = (num - remainder - 1) / alphabet_len;
69+
written_len += 1;
70+
}
71+
72+
if num > 0 {
73+
let mut vec_buf = Vec::with_capacity(20);
74+
vec_buf.extend_from_slice(&stack_buf);
75+
76+
while num > 0 {
77+
let remainder = (num - 1) % alphabet_len;
78+
vec_buf.push(convert_fn(remainder));
79+
num = (num - remainder - 1) / alphabet_len;
80+
written_len += 1;
81+
}
82+
83+
for _ in vec_buf.len()..width {
84+
buf.write_char('0')?;
85+
}
86+
for &c in vec_buf.iter().rev() {
87+
buf.write_char(c)?;
88+
}
89+
} else {
90+
for _ in written_len..width {
91+
buf.write_char('0')?;
92+
}
93+
for &c in stack_buf[..written_len].iter().rev() {
94+
buf.write_char(c)?;
95+
}
96+
}
97+
98+
Ok(())
99+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
//! This module provides functionality for creating and managing various formats
2+
//! of counters.
3+
//!
4+
//! Counters are used to generate sequences of values based on different
5+
//! alphabets and numeral systems, including ANSI, Cyrillic, and Roman letters,
6+
//! as well as digits.
7+
//!
8+
//! # Overview
9+
//!
10+
//! The module defines traits and structures for different formats of counters,
11+
//! including:
12+
//!
13+
//! - uppercase and lowercase ANSI letters;
14+
//! - uppercase and lowercase Cyrillic letters;
15+
//! - numeric counter;
16+
//! - uppercase and lowercase Roman numerals.
17+
//!
18+
//! The `CharacterCounter` structure provides a unified interface for handling
19+
//! these different formats of counters.
20+
21+
use super::filename_template::CounterBuilder;
22+
use std::fmt;
23+
24+
#[cfg(test)]
25+
mod test;
26+
27+
#[macro_use]
28+
mod geneal;
29+
mod ansi;
30+
mod cyrillic;
31+
mod digit;
32+
mod roman;
33+
34+
const UPPERCASE: bool = true;
35+
const LOWERCASE: bool = false;
36+
37+
pub use ansi::{AnsiLower, AnsiUpper};
38+
pub use cyrillic::{CyrillicLower, CyrillicUpper};
39+
pub use digit::Digits;
40+
use geneal::write_number_as_letters_gen;
41+
pub use roman::{RomanLower, RomanUpper};
42+
43+
/// Defines common behavior for counters that generate sequential values.
44+
pub trait Counter {
45+
/// Writes the current value to the provided buffer.
46+
fn write_value(&self, buf: &mut impl fmt::Write) -> fmt::Result;
47+
48+
/// Advances the counter to the next value in the sequence.
49+
fn advance(&mut self);
50+
51+
/// Resets the counter to its initial value.
52+
#[allow(dead_code)]
53+
fn restart(&mut self);
54+
}
55+
56+
pub trait CounterFormatter: Copy {
57+
/// Formats a value as a zero-padded string and writes it to a buffer.
58+
///
59+
/// # Arguments
60+
///
61+
/// * `value` - The numeric value to format.
62+
/// * `width` - The minimum width of the output string.
63+
/// * `buf` - A mutable reference to a buffer.
64+
fn value_to_buffer(
65+
self,
66+
value: u32,
67+
width: usize,
68+
buf: &mut impl fmt::Write,
69+
) -> Result<(), fmt::Error>;
70+
71+
/// Parses a zero-padded numeric string into a `u32` value.
72+
fn string_to_value(self, value: &str) -> Option<u32>;
73+
}
74+
75+
/// Enum representing different formats of character-based counters.
76+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77+
pub enum CounterFormat {
78+
/// Numeric values (1, 2, ..., 999).
79+
Digits(Digits),
80+
81+
/// Uppercase ANSI letters (A, B, C, ..., AA, AB, ...).
82+
AnsiUpper(AnsiUpper),
83+
84+
/// Lowercase ANSI letters (a, b, c, ..., aa, ab, ...).
85+
AnsiLower(AnsiLower),
86+
87+
/// Uppercase Roman numerals (I, II, III, IV, V, ...).
88+
RomanUpper(RomanUpper),
89+
90+
/// Lowercase Roman numerals (i, ii, iii, iv, v, ...).
91+
RomanLower(RomanLower),
92+
93+
/// Uppercase Cyrillic letters (А, Б, В, ..., АА, АБ, ...).
94+
CyrillicUpper(CyrillicUpper),
95+
96+
/// Lowercase Cyrillic letters (а, б, в, ..., аа, аб, ...).
97+
CyrillicLower(CyrillicLower),
98+
}
99+
100+
impl Default for CounterFormat {
101+
fn default() -> Self {
102+
CounterFormat::Digits(Digits)
103+
}
104+
}
105+
106+
impl CounterFormatter for CounterFormat {
107+
fn value_to_buffer(
108+
self,
109+
value: u32,
110+
width: usize,
111+
buf: &mut impl fmt::Write,
112+
) -> Result<(), fmt::Error> {
113+
match self {
114+
CounterFormat::Digits(fmt) => fmt.value_to_buffer(value, width, buf),
115+
CounterFormat::AnsiUpper(fmt) => fmt.value_to_buffer(value, width, buf),
116+
CounterFormat::AnsiLower(fmt) => fmt.value_to_buffer(value, width, buf),
117+
CounterFormat::RomanUpper(fmt) => fmt.value_to_buffer(value, width, buf),
118+
CounterFormat::RomanLower(fmt) => fmt.value_to_buffer(value, width, buf),
119+
CounterFormat::CyrillicUpper(fmt) => fmt.value_to_buffer(value, width, buf),
120+
CounterFormat::CyrillicLower(fmt) => fmt.value_to_buffer(value, width, buf),
121+
}
122+
}
123+
124+
fn string_to_value(self, value: &str) -> Option<u32> {
125+
match self {
126+
CounterFormat::Digits(fmt) => fmt.string_to_value(value),
127+
CounterFormat::AnsiUpper(fmt) => fmt.string_to_value(value),
128+
CounterFormat::AnsiLower(fmt) => fmt.string_to_value(value),
129+
CounterFormat::RomanUpper(fmt) => fmt.string_to_value(value),
130+
CounterFormat::RomanLower(fmt) => fmt.string_to_value(value),
131+
CounterFormat::CyrillicUpper(fmt) => fmt.string_to_value(value),
132+
CounterFormat::CyrillicLower(fmt) => fmt.string_to_value(value),
133+
}
134+
}
135+
}
136+
137+
impl fmt::Display for CounterFormat {
138+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139+
match self {
140+
CounterFormat::Digits(_) => write!(f, "Numeric Digits"),
141+
CounterFormat::AnsiUpper(_) => write!(f, "ANSI Uppercase Letters"),
142+
CounterFormat::AnsiLower(_) => write!(f, "ANSI Lowercase Letters"),
143+
CounterFormat::RomanUpper(_) => write!(f, "Roman Uppercase Numerals"),
144+
CounterFormat::RomanLower(_) => write!(f, "Roman Lowercase Numerals"),
145+
CounterFormat::CyrillicUpper(_) => write!(f, "Cyrillic Uppercase Letters"),
146+
CounterFormat::CyrillicLower(_) => write!(f, "Cyrillic Lowercase Letters"),
147+
}
148+
}
149+
}
150+
151+
/// Represents a character-based counter. Provides a unified interface for
152+
/// handling different formats of counters.
153+
#[derive(Debug, Clone, PartialEq, Eq)]
154+
pub struct CharacterCounter {
155+
/// The format of counter (e.g., ANSI, Cyrillic, Roman, digits).
156+
format: CounterFormat,
157+
158+
/// The initial numeric value of the counter, used to reset the counter.
159+
start: u32,
160+
161+
/// The current numeric value of the counter.
162+
state: u32,
163+
164+
/// The increment step size when advancing the counter.
165+
step: u32,
166+
167+
/// The minimum width of the generated string, padded with leading zeros.
168+
width: usize,
169+
}
170+
171+
impl CharacterCounter {
172+
/// Creates a new `CharacterCounter` instance.
173+
///
174+
/// # Arguments
175+
///
176+
/// * `format` - the format of counter (e.g., ANSI, Cyrillic, Roman, digits).
177+
/// * `start` - the initial numeric value of the counter.
178+
/// * `step` - the increment step size when advancing the counter.
179+
/// * `width` - the minimum width of the generated string, padded with leading zeros.
180+
pub fn new(format: CounterFormat, start: u32, step: u32, width: usize) -> Self {
181+
Self { format, start, state: start, step, width }
182+
}
183+
184+
/// Updates the `CharacterCounter` instance with the parameters set in
185+
/// builder.
186+
pub fn update_from(&mut self, builder: CounterBuilder) {
187+
if self.format != builder.format() {
188+
self.format = builder.format();
189+
}
190+
191+
if let Some(start) = builder.start() {
192+
self.start = start;
193+
self.state = start;
194+
}
195+
196+
if let Some(step) = builder.step() {
197+
self.step = step;
198+
}
199+
200+
if let Some(width) = builder.width() {
201+
self.width = width;
202+
}
203+
}
204+
}
205+
206+
impl Counter for CharacterCounter {
207+
fn write_value(&self, buf: &mut impl fmt::Write) -> fmt::Result {
208+
self.format.value_to_buffer(self.state, self.width, buf)
209+
}
210+
211+
fn advance(&mut self) {
212+
self.state += self.step;
213+
}
214+
215+
fn restart(&mut self) {
216+
self.state = self.start;
217+
}
218+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
//! This module provides functionality for managing Roman numeral counters
2+
//! for both uppercase and lowercase Roman numerals.
3+
4+
use super::{CounterFormatter, LOWERCASE, UPPERCASE};
5+
use std::fmt;
6+
7+
/// A lookup table for uppercase Roman numerals and their values.
8+
const UPPERCASE_ROMAN_NUMERALS: [(&str, u32); 13] = [
9+
("M", 1000),
10+
("CM", 900),
11+
("D", 500),
12+
("CD", 400),
13+
("C", 100),
14+
("XC", 90),
15+
("L", 50),
16+
("XL", 40),
17+
("X", 10),
18+
("IX", 9),
19+
("V", 5),
20+
("IV", 4),
21+
("I", 1),
22+
];
23+
24+
/// A lookup table for lowercase Roman numerals and their values.
25+
const LOWERCASE_ROMAN_NUMERALS: [(&str, u32); 13] = [
26+
("m", 1000),
27+
("cm", 900),
28+
("d", 500),
29+
("cd", 400),
30+
("c", 100),
31+
("xc", 90),
32+
("l", 50),
33+
("xl", 40),
34+
("x", 10),
35+
("ix", 9),
36+
("v", 5),
37+
("iv", 4),
38+
("i", 1),
39+
];
40+
41+
/// A helper structure for generating uppercase Roman numerals (e.g., I, II, III, IV, V, ...).
42+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43+
pub struct RomanUpper;
44+
45+
/// A helper structure for generating lowercase Roman numerals (e.g., i, ii, iii, iv, v, ...).
46+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47+
pub struct RomanLower;
48+
49+
impl_counter_formatter! { RomanUpper, UPPERCASE }
50+
impl_counter_formatter! { RomanLower, LOWERCASE }
51+
52+
/// Converts Roman numerals (e.g. I, II, III) to their corresponding numeric values.
53+
///
54+
/// The `UPPERCASE` constant determines whether the string should be validated
55+
/// as uppercase or lowercase.
56+
///
57+
/// # Returns
58+
///
59+
/// Returns `Some(u32)` if conversion is successful; otherwise, returns `None`.
60+
#[inline]
61+
fn convert_letters_to_number<const UPPERCASE: bool>(start: &str) -> Option<u32> {
62+
if invalid_string::<UPPERCASE>(start) {
63+
return None;
64+
};
65+
let roman_numerals =
66+
if UPPERCASE { &UPPERCASE_ROMAN_NUMERALS } else { &LOWERCASE_ROMAN_NUMERALS };
67+
68+
let mut num = 0;
69+
let mut i = 0;
70+
71+
while i < start.len() {
72+
if i + 1 < start.len() {
73+
if let Some(&(_, value)) = roman_numerals.iter().find(|&&(s, _)| s == &start[i..=i + 1]) {
74+
num += value;
75+
i += 2;
76+
continue;
77+
}
78+
}
79+
if let Some(&(_, value)) = roman_numerals.iter().find(|&&(s, _)| s == &start[i..=i]) {
80+
num += value;
81+
i += 1;
82+
} else {
83+
return None;
84+
}
85+
}
86+
87+
Some(num)
88+
}
89+
90+
/// Writes the numeric value as Roman numerals (e.g., 1 → "I", 4 → "IV") into the
91+
/// provided buffer.
92+
///
93+
/// # Arguments
94+
///
95+
/// * `num` - The numeric value to convert.
96+
/// * `width` - The minimum width of the generated string, padded with zeros if necessary.
97+
/// * `buf` - The buffer to write the resulting string into.
98+
#[inline]
99+
fn write_number_as_letters<const UPPERCASE: bool>(
100+
mut num: u32,
101+
width: usize,
102+
buf: &mut impl fmt::Write,
103+
) -> fmt::Result {
104+
if num == 0 {
105+
return Ok(());
106+
}
107+
108+
let roman_numerals =
109+
if UPPERCASE { &UPPERCASE_ROMAN_NUMERALS } else { &LOWERCASE_ROMAN_NUMERALS };
110+
111+
let mut stack_buf = ['0'; 10];
112+
let mut length = 0;
113+
114+
let mut iter = roman_numerals.iter().peekable();
115+
'outer: while let Some(&&(roman, value)) = iter.peek() {
116+
'inner: loop {
117+
if num < value {
118+
break 'inner;
119+
}
120+
let final_length = length + roman.len();
121+
if final_length > stack_buf.len() {
122+
break 'outer;
123+
}
124+
for (char_ref, char) in stack_buf[length..final_length].iter_mut().zip(roman.chars()) {
125+
*char_ref = char
126+
}
127+
num -= value;
128+
length += roman.len();
129+
}
130+
iter.next();
131+
}
132+
133+
if num > 0 {
134+
let mut vec_buf = Vec::with_capacity(20);
135+
vec_buf.extend_from_slice(&stack_buf[..length]);
136+
137+
for &(roman, value) in iter {
138+
while num >= value {
139+
vec_buf.extend(roman.chars());
140+
num -= value;
141+
length += roman.len();
142+
}
143+
}
144+
145+
for _ in vec_buf.len()..width {
146+
buf.write_char('0')?;
147+
}
148+
for &c in vec_buf.iter() {
149+
buf.write_char(c)?;
150+
}
151+
} else {
152+
for _ in length..width {
153+
buf.write_char('0')?;
154+
}
155+
for &c in stack_buf[..length].iter() {
156+
buf.write_char(c)?;
157+
}
158+
}
159+
160+
Ok(())
161+
}
162+
163+
/// Checks if a string is non-empty and consists only of valid
164+
/// uppercase or lowercase Roman numerals.
165+
///
166+
/// The `UPPERCASE` constant determines whether to check uppercase or lowercase letters.
167+
///
168+
/// # Returns
169+
///
170+
/// Returns `true` if the string is invalid; otherwise, returns `false`.
171+
#[inline]
172+
fn invalid_string<const UPPERCASE: bool>(str: &str) -> bool {
173+
if str.is_empty() {
174+
return true;
175+
}
176+
let valid_chars = if UPPERCASE {
177+
['M', 'D', 'C', 'L', 'X', 'V', 'I']
178+
} else {
179+
['m', 'd', 'c', 'l', 'x', 'v', 'i']
180+
};
181+
182+
!str.chars().all(|c| valid_chars.contains(&c))
183+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
use super::*;
2+
3+
const DIGITS_VALUES: [&str; 100] = [
4+
"000", "001", "002", "003", "004", "005", "006", "007", "008", "009", "010", "011", "012", "013",
5+
"014", "015", "016", "017", "018", "019", "020", "021", "022", "023", "024", "025", "026", "027",
6+
"028", "029", "030", "031", "032", "033", "034", "035", "036", "037", "038", "039", "040", "041",
7+
"042", "043", "044", "045", "046", "047", "048", "049", "050", "051", "052", "053", "054", "055",
8+
"056", "057", "058", "059", "060", "061", "062", "063", "064", "065", "066", "067", "068", "069",
9+
"070", "071", "072", "073", "074", "075", "076", "077", "078", "079", "080", "081", "082", "083",
10+
"084", "085", "086", "087", "088", "089", "090", "091", "092", "093", "094", "095", "096", "097",
11+
"098", "099",
12+
];
13+
14+
#[test]
15+
fn test_digits_advance_100_iterations() {
16+
let mut buf = String::new();
17+
let counter = Digits;
18+
for (idx, &expected_value) in DIGITS_VALUES.iter().enumerate() {
19+
let _ = counter.value_to_buffer(idx as u32, 3, &mut buf);
20+
assert_eq!(expected_value, &buf);
21+
buf.clear();
22+
}
23+
24+
for (idx, &expected_value) in DIGITS_VALUES.iter().enumerate() {
25+
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32));
26+
}
27+
}
28+
29+
const UPPERCASE_ANSI_VALUES: [&str; 100] = [
30+
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S",
31+
"T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ",
32+
"AK", "AL", "AM", "AN", "AO", "AP", "AQ", "AR", "AS", "AT", "AU", "AV", "AW", "AX", "AY", "AZ",
33+
"BA", "BB", "BC", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BK", "BL", "BM", "BN", "BO", "BP",
34+
"BQ", "BR", "BS", "BT", "BU", "BV", "BW", "BX", "BY", "BZ", "CA", "CB", "CC", "CD", "CE", "CF",
35+
"CG", "CH", "CI", "CJ", "CK", "CL", "CM", "CN", "CO", "CP", "CQ", "CR", "CS", "CT", "CU", "CV",
36+
];
37+
38+
const LOWERCASE_ANSI_VALUES: [&str; 100] = [
39+
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s",
40+
"t", "u", "v", "w", "x", "y", "z", "aa", "ab", "ac", "ad", "ae", "af", "ag", "ah", "ai", "aj",
41+
"ak", "al", "am", "an", "ao", "ap", "aq", "ar", "as", "at", "au", "av", "aw", "ax", "ay", "az",
42+
"ba", "bb", "bc", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bk", "bl", "bm", "bn", "bo", "bp",
43+
"bq", "br", "bs", "bt", "bu", "bv", "bw", "bx", "by", "bz", "ca", "cb", "cc", "cd", "ce", "cf",
44+
"cg", "ch", "ci", "cj", "ck", "cl", "cm", "cn", "co", "cp", "cq", "cr", "cs", "ct", "cu", "cv",
45+
];
46+
47+
#[test]
48+
fn test_ansi_upper_advance_100_iterations() {
49+
let mut buf = String::new();
50+
let counter = AnsiUpper;
51+
for (idx, &expected_value) in UPPERCASE_ANSI_VALUES.iter().enumerate() {
52+
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
53+
assert_eq!(expected_value, &buf);
54+
buf.clear();
55+
}
56+
57+
for (idx, &expected_value) in UPPERCASE_ANSI_VALUES.iter().enumerate() {
58+
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
59+
}
60+
}
61+
62+
#[test]
63+
fn test_ansi_lower_advance_100_iterations() {
64+
let mut buf = String::new();
65+
let counter = AnsiLower;
66+
for (idx, &expected_value) in LOWERCASE_ANSI_VALUES.iter().enumerate() {
67+
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
68+
assert_eq!(expected_value, &buf);
69+
buf.clear();
70+
}
71+
72+
for (idx, &expected_value) in LOWERCASE_ANSI_VALUES.iter().enumerate() {
73+
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
74+
}
75+
}
76+
77+
const UPPERCASE_ROMAN_VALUES: [&str; 100] = [
78+
"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII", "XIII", "XIV", "XV",
79+
"XVI", "XVII", "XVIII", "XIX", "XX", "XXI", "XXII", "XXIII", "XXIV", "XXV", "XXVI", "XXVII",
80+
"XXVIII", "XXIX", "XXX", "XXXI", "XXXII", "XXXIII", "XXXIV", "XXXV", "XXXVI", "XXXVII",
81+
"XXXVIII", "XXXIX", "XL", "XLI", "XLII", "XLIII", "XLIV", "XLV", "XLVI", "XLVII", "XLVIII",
82+
"XLIX", "L", "LI", "LII", "LIII", "LIV", "LV", "LVI", "LVII", "LVIII", "LIX", "LX", "LXI",
83+
"LXII", "LXIII", "LXIV", "LXV", "LXVI", "LXVII", "LXVIII", "LXIX", "LXX", "LXXI", "LXXII",
84+
"LXXIII", "LXXIV", "LXXV", "LXXVI", "LXXVII", "LXXVIII", "LXXIX", "LXXX", "LXXXI", "LXXXII",
85+
"LXXXIII", "LXXXIV", "LXXXV", "LXXXVI", "LXXXVII", "LXXXVIII", "LXXXIX", "XC", "XCI", "XCII",
86+
"XCIII", "XCIV", "XCV", "XCVI", "XCVII", "XCVIII", "XCIX", "C",
87+
];
88+
89+
const LOWERCASE_ROMAN_VALUES: [&str; 100] = [
90+
"i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii", "xiii", "xiv", "xv",
91+
"xvi", "xvii", "xviii", "xix", "xx", "xxi", "xxii", "xxiii", "xxiv", "xxv", "xxvi", "xxvii",
92+
"xxviii", "xxix", "xxx", "xxxi", "xxxii", "xxxiii", "xxxiv", "xxxv", "xxxvi", "xxxvii",
93+
"xxxviii", "xxxix", "xl", "xli", "xlii", "xliii", "xliv", "xlv", "xlvi", "xlvii", "xlviii",
94+
"xlix", "l", "li", "lii", "liii", "liv", "lv", "lvi", "lvii", "lviii", "lix", "lx", "lxi",
95+
"lxii", "lxiii", "lxiv", "lxv", "lxvi", "lxvii", "lxviii", "lxix", "lxx", "lxxi", "lxxii",
96+
"lxxiii", "lxxiv", "lxxv", "lxxvi", "lxxvii", "lxxviii", "lxxix", "lxxx", "lxxxi", "lxxxii",
97+
"lxxxiii", "lxxxiv", "lxxxv", "lxxxvi", "lxxxvii", "lxxxviii", "lxxxix", "xc", "xci", "xcii",
98+
"xciii", "xciv", "xcv", "xcvi", "xcvii", "xcviii", "xcix", "c",
99+
];
100+
101+
#[test]
102+
fn test_roman_upper_advance_100_iterations() {
103+
let mut buf = String::new();
104+
let counter = RomanUpper;
105+
for (idx, &expected_value) in UPPERCASE_ROMAN_VALUES.iter().enumerate() {
106+
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
107+
assert_eq!(expected_value, &buf);
108+
buf.clear();
109+
}
110+
111+
for (idx, &expected_value) in UPPERCASE_ROMAN_VALUES.iter().enumerate() {
112+
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
113+
}
114+
}
115+
116+
#[test]
117+
fn test_roman_lower_advance_100_iterations() {
118+
let mut buf = String::new();
119+
let counter = RomanLower;
120+
for (idx, &expected_value) in LOWERCASE_ROMAN_VALUES.iter().enumerate() {
121+
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
122+
assert_eq!(expected_value, &buf);
123+
buf.clear();
124+
}
125+
126+
for (idx, &expected_value) in LOWERCASE_ROMAN_VALUES.iter().enumerate() {
127+
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
128+
}
129+
}
130+
131+
const UPPERCASE_CYRILLIC_VALUES: [&str; 100] = [
132+
"А", "Б", "В", "Г", "Д", "Е", "Ж", "З", "И", "К", "Л", "М", "Н", "О", "П", "Р", "С", "Т", "У",
133+
"Ф", "Х", "Ц", "Ч", "Ш", "Щ", "Э", "Ю", "Я", "АА", "АБ", "АВ", "АГ", "АД", "АЕ", "АЖ", "АЗ",
134+
"АИ", "АК", "АЛ", "АМ", "АН", "АО", "АП", "АР", "АС", "АТ", "АУ", "АФ", "АХ", "АЦ", "АЧ", "АШ",
135+
"АЩ", "АЭ", "АЮ", "АЯ", "БА", "ББ", "БВ", "БГ", "БД", "БЕ", "БЖ", "БЗ", "БИ", "БК", "БЛ", "БМ",
136+
"БН", "БО", "БП", "БР", "БС", "БТ", "БУ", "БФ", "БХ", "БЦ", "БЧ", "БШ", "БЩ", "БЭ", "БЮ", "БЯ",
137+
"ВА", "ВБ", "ВВ", "ВГ", "ВД", "ВЕ", "ВЖ", "ВЗ", "ВИ", "ВК", "ВЛ", "ВМ", "ВН", "ВО", "ВП", "ВР",
138+
];
139+
140+
const LOWERCASE_CYRILLIC_VALUES: [&str; 100] = [
141+
"а", "б", "в", "г", "д", "е", "ж", "з", "и", "к", "л", "м", "н", "о", "п", "р", "с", "т", "у",
142+
"ф", "х", "ц", "ч", "ш", "щ", "э", "ю", "я", "аа", "аб", "ав", "аг", "ад", "ае", "аж", "аз",
143+
"аи", "ак", "ал", "ам", "ан", "ао", "ап", "ар", "ас", "ат", "ау", "аф", "ах", "ац", "ач", "аш",
144+
"ащ", "аэ", "аю", "ая", "ба", "бб", "бв", "бг", "бд", "бе", "бж", "бз", "би", "бк", "бл", "бм",
145+
"бн", "бо", "бп", "бр", "бс", "бт", "бу", "бф", "бх", "бц", "бч", "бш", "бщ", "бэ", "бю", "бя",
146+
"ва", "вб", "вв", "вг", "вд", "ве", "вж", "вз", "ви", "вк", "вл", "вм", "вн", "во", "вп", "вр",
147+
];
148+
149+
#[test]
150+
fn test_cyrillic_upper_advance_100_iterations() {
151+
let mut buf = String::new();
152+
let counter = CyrillicUpper;
153+
for (idx, &expected_value) in UPPERCASE_CYRILLIC_VALUES.iter().enumerate() {
154+
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
155+
assert_eq!(expected_value, &buf);
156+
buf.clear();
157+
}
158+
159+
for (idx, &expected_value) in UPPERCASE_CYRILLIC_VALUES.iter().enumerate() {
160+
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
161+
}
162+
}
163+
164+
#[test]
165+
fn test_cyrillic_lower_advance_100_iterations() {
166+
let mut buf = String::new();
167+
let counter = CyrillicLower;
168+
for (idx, &expected_value) in LOWERCASE_CYRILLIC_VALUES.iter().enumerate() {
169+
let _ = counter.value_to_buffer(idx as u32 + 1, 1, &mut buf);
170+
assert_eq!(expected_value, &buf);
171+
buf.clear();
172+
}
173+
174+
for (idx, &expected_value) in LOWERCASE_CYRILLIC_VALUES.iter().enumerate() {
175+
assert_eq!(counter.string_to_value(expected_value), Some(idx as u32 + 1));
176+
}
177+
}

‎yazi-core/src/mgr/commands/bulk_rename/filename_template/mod.rs

+693
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
use super::*;
2+
3+
#[test]
4+
fn test_new_creates_default_builder() {
5+
let builder = CounterBuilder::new();
6+
assert_eq!(builder.format(), CounterFormat::Digits(Digits));
7+
assert_eq!(builder.start(), None);
8+
assert_eq!(builder.step(), None);
9+
assert_eq!(builder.width(), None);
10+
}
11+
12+
#[test]
13+
fn test_try_set_format() {
14+
let builder = CounterBuilder::new();
15+
16+
// Test all supported formats
17+
let formats = [
18+
("D", " D ", CounterFormat::Digits(Digits)),
19+
("d", " d ", CounterFormat::Digits(Digits)),
20+
("N", " N ", CounterFormat::Digits(Digits)),
21+
("n", " n ", CounterFormat::Digits(Digits)),
22+
("A", " A ", CounterFormat::AnsiUpper(AnsiUpper)),
23+
("a", " a ", CounterFormat::AnsiLower(AnsiLower)),
24+
("R", " R ", CounterFormat::RomanUpper(RomanUpper)),
25+
("r", " r ", CounterFormat::RomanLower(RomanLower)),
26+
("C", " C ", CounterFormat::CyrillicUpper(CyrillicUpper)),
27+
("c", " c ", CounterFormat::CyrillicLower(CyrillicLower)),
28+
];
29+
30+
for (without_space, with_space, expected) in formats.iter() {
31+
let result = builder.try_set_format(without_space, 0..1, without_space);
32+
assert_eq!(result.unwrap().format(), *expected,);
33+
let result = builder.try_set_format(with_space, 0..5, with_space);
34+
assert_eq!(result.unwrap().format(), *expected,);
35+
}
36+
37+
let result = builder.try_set_format("", 0..0, "");
38+
let error = result.unwrap_err();
39+
assert_eq!(error.reason, "Empty counter kind");
40+
assert_eq!(error.span, 0..0);
41+
assert_eq!(error.expected, Some("one of D, d, N, n, A, a, R, r, C, c"));
42+
assert_eq!(error.found, None);
43+
44+
let result =
45+
CounterBuilder::new().try_set_format(" Ü-Wagen as examplé ", 2..27, " Ü-Wagen as examplé ");
46+
let error = result.unwrap_err();
47+
assert_eq!(error.reason, "Unexpected counter kind");
48+
assert_eq!(error.span, 4..25);
49+
assert_eq!(error.expected, Some("one of D, d, N, n, A, a, R, r, C, c"));
50+
assert_eq!(error.found, Some("Ü-Wagen as examplé"));
51+
}
52+
53+
#[test]
54+
fn test_try_set_start() {
55+
let builder = CounterBuilder::new();
56+
let result = builder.try_set_start("n,5", Some((2..3, "5"))).unwrap();
57+
assert_eq!(result.start(), Some(5));
58+
59+
let formats = [
60+
("D", "25", 25),
61+
("d", "25", 25),
62+
("N", "25", 25),
63+
("n", "25", 25),
64+
("A", "AB", 28),
65+
("a", "ab", 28),
66+
("R", "IV", 4),
67+
("r", "iv", 4),
68+
("C", "АБ", 30),
69+
("c", "аб", 30),
70+
];
71+
72+
for (format, start, expected) in formats.iter() {
73+
let builder = builder.try_set_format(format, 0..1, format).unwrap();
74+
let result = builder.try_set_start(format, Some((2..4, start))).unwrap();
75+
assert_eq!(result.start(), Some(*expected));
76+
}
77+
78+
let result = builder.try_set_start("_", Some((2..3, "_"))).unwrap();
79+
assert_eq!(result.start(), None);
80+
81+
let result = builder.try_set_start(" 5 ", Some((0..5, " 5 "))).unwrap();
82+
assert_eq!(result.start(), Some(5));
83+
84+
let result =
85+
builder.try_set_start(" Ü-Wagen as examplé ", Some((2..27, " Ü-Wagen as examplé ")));
86+
let error = result.unwrap_err();
87+
assert_eq!(error.span, 4..25);
88+
assert_eq!(error.expected, Some("digit"));
89+
assert_eq!(error.found, Some("Ü-Wagen as examplé"));
90+
}
91+
92+
#[test]
93+
fn test_try_set_step() {
94+
let builder = CounterBuilder::new();
95+
let result = builder.try_set_step("5", Some((2..3, "5"))).unwrap();
96+
assert_eq!(result.step(), Some(5));
97+
98+
let result = builder.try_set_step("_", Some((2..3, "_"))).unwrap();
99+
assert_eq!(result.step(), None);
100+
101+
let result = builder.try_set_step(" 5 ", Some((0..5, " 5 "))).unwrap();
102+
assert_eq!(result.step(), Some(5));
103+
104+
let result =
105+
builder.try_set_step(" Ü-Wagen as examplé ", Some((2..27, " Ü-Wagen as examplé ")));
106+
let error = result.unwrap_err();
107+
assert_eq!(error.span, 4..25);
108+
assert_eq!(error.expected, Some("digit"));
109+
assert_eq!(error.found, Some("Ü-Wagen as examplé"));
110+
}
111+
112+
#[test]
113+
fn test_try_set_width() {
114+
let builder = CounterBuilder::new();
115+
let result = builder.try_set_width("5", Some((2..3, "5"))).unwrap();
116+
assert_eq!(result.width(), Some(5));
117+
118+
let result = builder.try_set_width("_", Some((2..3, "_"))).unwrap();
119+
assert_eq!(result.width(), None);
120+
121+
let result = builder.try_set_width(" 5 ", Some((0..5, " 5 "))).unwrap();
122+
assert_eq!(result.width(), Some(5));
123+
124+
let result =
125+
builder.try_set_width(" Ü-Wagen as examplé ", Some((2..27, " Ü-Wagen as examplé ")));
126+
let error = result.unwrap_err();
127+
assert_eq!(error.span, 4..25);
128+
assert_eq!(error.expected, Some("digit"));
129+
assert_eq!(error.found, Some("Ü-Wagen as examplé"));
130+
}
131+
132+
#[test]
133+
fn test_build_with_all_parameters() {
134+
let builder = CounterBuilder::new()
135+
.try_set_format("N,10,2,3", 0..1, "N")
136+
.unwrap()
137+
.try_set_start("N,10,2,3", Some((2..4, "10")))
138+
.unwrap()
139+
.try_set_step("N,10,2,3", Some((5..6, "2")))
140+
.unwrap()
141+
.try_set_width("N,10,2,3", Some((7..8, "3")))
142+
.unwrap();
143+
let counter = builder.build();
144+
let mut buf = String::new();
145+
counter.write_value(&mut buf).unwrap();
146+
assert_eq!(buf, "010"); // Digits format, width 3
147+
}
148+
149+
#[test]
150+
fn test_template_parse_no_counters() {
151+
match Template::parse("plain text without counters") {
152+
Err(TemplateError::NotCounter) => {}
153+
_ => panic!("Expected NotCounter error"),
154+
}
155+
}
156+
157+
#[test]
158+
fn test_template_parse_counters() {
159+
use super::{CounterFormat as CF, TemplatePart as TP};
160+
161+
// test includes escaped counters %%{R,3,4,5}
162+
let inputs: [(&str, CF, CF, CF, CF, CF, Option<u32>, Option<u32>, Option<usize>); 8] = [
163+
(
164+
"Ü-%%{R,3,4,5}_%{D}_examplé_%{d}_你好_%{N}_слово_%{n}_word_%{A}.txt",
165+
CF::Digits(Digits),
166+
CF::Digits(Digits),
167+
CF::Digits(Digits),
168+
CF::Digits(Digits),
169+
CF::AnsiUpper(AnsiUpper),
170+
None,
171+
None,
172+
None,
173+
),
174+
(
175+
"Ü-%%{R,3,4,5}_%{a}_examplé_%{R}_你好_%{r}_слово_%{C}_word_%{c}.txt",
176+
CF::AnsiLower(AnsiLower),
177+
CF::RomanUpper(RomanUpper),
178+
CF::RomanLower(RomanLower),
179+
CF::CyrillicUpper(CyrillicUpper),
180+
CF::CyrillicLower(CyrillicLower),
181+
None,
182+
None,
183+
None,
184+
),
185+
(
186+
"Ü-%%{R,3,4,5}_%{D,3}_examplé_%{d,3}_你好_%{N,3}_слово_%{n,3}_word_%{A,3}.txt",
187+
CF::Digits(Digits),
188+
CF::Digits(Digits),
189+
CF::Digits(Digits),
190+
CF::Digits(Digits),
191+
CF::AnsiUpper(AnsiUpper),
192+
Some(3),
193+
None,
194+
None,
195+
),
196+
(
197+
"Ü-%%{R,3,4,5}_%{a,c}_examplé_%{R,3}_你好_%{r,iii}_слово_%{C,3}_word_%{c,в}.txt",
198+
CF::AnsiLower(AnsiLower),
199+
CF::RomanUpper(RomanUpper),
200+
CF::RomanLower(RomanLower),
201+
CF::CyrillicUpper(CyrillicUpper),
202+
CF::CyrillicLower(CyrillicLower),
203+
Some(3),
204+
None,
205+
None,
206+
),
207+
(
208+
"Ü-%%{R,3,4,5}_%{D,3,4}_examplé_%{d,3,4}_你好_%{N,3,4}_слово_%{n,3,4}_word_%{A,3,4}.txt",
209+
CF::Digits(Digits),
210+
CF::Digits(Digits),
211+
CF::Digits(Digits),
212+
CF::Digits(Digits),
213+
CF::AnsiUpper(AnsiUpper),
214+
Some(3),
215+
Some(4),
216+
None,
217+
),
218+
(
219+
"Ü-%%{R,3,4,5}_%{a,c,14}_examplé_%{R,3,14}_你好_%{r,iii,14}_слово_%{C,3,14}_word_%{c,в,14}.txt",
220+
CF::AnsiLower(AnsiLower),
221+
CF::RomanUpper(RomanUpper),
222+
CF::RomanLower(RomanLower),
223+
CF::CyrillicUpper(CyrillicUpper),
224+
CF::CyrillicLower(CyrillicLower),
225+
Some(3),
226+
Some(14),
227+
None,
228+
),
229+
(
230+
"Ü-%%{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",
231+
CF::Digits(Digits),
232+
CF::Digits(Digits),
233+
CF::Digits(Digits),
234+
CF::Digits(Digits),
235+
CF::AnsiUpper(AnsiUpper),
236+
Some(3),
237+
Some(4),
238+
Some(55),
239+
),
240+
(
241+
"Ü-%%{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",
242+
CF::AnsiLower(AnsiLower),
243+
CF::RomanUpper(RomanUpper),
244+
CF::RomanLower(RomanLower),
245+
CF::CyrillicUpper(CyrillicUpper),
246+
CF::CyrillicLower(CyrillicLower),
247+
Some(3),
248+
Some(14),
249+
Some(6),
250+
),
251+
];
252+
253+
for (idx, &(input, c1, c2, c3, c4, c5, start, step, width)) in inputs.iter().enumerate() {
254+
let parsed = Template::parse(input).expect("Should parse a single counter");
255+
assert_eq!(parsed.counter_count(), 5);
256+
257+
assert_eq!(
258+
parsed.parts(),
259+
&[
260+
TP::Text("Ü-"),
261+
TP::Text("%{"),
262+
TP::Text("R,3,4,5}_"),
263+
TP::CounterBuilder(CounterBuilder { format: c1, start, step, width }),
264+
TP::Text("_example\u{301}_"),
265+
TP::CounterBuilder(CounterBuilder { format: c2, start, step, width }),
266+
TP::Text("_你好_"),
267+
TP::CounterBuilder(CounterBuilder { format: c3, start, step, width }),
268+
TP::Text("_слово_"),
269+
TP::CounterBuilder(CounterBuilder { format: c4, start, step, width }),
270+
TP::Text("_word_"),
271+
TP::CounterBuilder(CounterBuilder { format: c5, start, step, width }),
272+
TP::Text(".txt")
273+
],
274+
"Failed to pass {} index",
275+
idx
276+
);
277+
}
278+
}
279+
280+
#[test]
281+
fn test_template_parse_unclosed_delimiter() {
282+
let input = "file_%{N.txt";
283+
match Template::parse(input) {
284+
Err(TemplateError::Parse(parse_err)) => {
285+
assert_eq!(parse_err.reason, "Unclosed delimiter");
286+
assert_eq!(parse_err.expected, Some("}"));
287+
}
288+
_ => panic!("Expected 'unclosed delimiter'."),
289+
}
290+
}
291+
292+
#[test]
293+
fn test_template_parse_extra_commas() {
294+
let input = "some %{N,2,2,2,} text";
295+
let result = Template::parse(input);
296+
match result {
297+
Err(TemplateError::Parse(parse_err)) => {
298+
assert_eq!(
299+
parse_err,
300+
ParseError {
301+
input: "some %{N,2,2,2,} text",
302+
span: 14..15,
303+
reason: "Extra arguments",
304+
expected: Some("no additional arguments"),
305+
found: None,
306+
}
307+
);
308+
}
309+
Ok(_) => panic!("Expected TemplateError::Parse"),
310+
Err(_) => panic!("Expected TemplateError::Parse"),
311+
}
312+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
//! This module provides functionality to generate file paths from input strings
2+
//! containing counters and template variables. It parses lines of text and
3+
//! produces either fixed filenames or filenames based on templates with
4+
//! dynamically updated counters.
5+
6+
use std::{error::Error, fmt, fmt::Write, ops::Range, path::PathBuf};
7+
8+
use unicode_width::UnicodeWidthStr;
9+
10+
use super::{
11+
counters::Counter,
12+
filename_template::{ParseError, ParsedLine, TemplatePart},
13+
};
14+
15+
#[cfg(test)]
16+
mod tests;
17+
18+
/// Generates a sequence of file paths from an input lines of text.
19+
///
20+
/// Each line is parsed into either a fixed filename or a template containing
21+
/// counters. If the line contains a template, counters are created and
22+
/// dynamically updated across lines, ensuring consistent numbering and
23+
/// formatting.
24+
///
25+
/// For details on counter syntax and template parsing, see
26+
/// [`super::filename_template::Template::parse`].
27+
///
28+
/// If any line contains counters, the number of counters must remain consistent
29+
/// across all lines. If there's a mismatch in the expected and actual number of
30+
/// counters in any line, the function records a
31+
/// [`PathGenError::MismatchCounters`].
32+
///
33+
/// # Error Handling
34+
///
35+
/// Instead of stopping execution at the first encountered error, the function
36+
/// collects all errors in a [`Vec<PathGenError>`], allowing the caller to see
37+
/// every problematic line at once.
38+
///
39+
/// # Flexibility
40+
///
41+
/// While the number of counters per line must be consistent, individual counter
42+
/// parameters (such as format, start, step, and width) may vary line by line.
43+
/// Counters update their values accordingly based on each line’s
44+
/// specifications.
45+
///
46+
/// # Arguments
47+
///
48+
/// * `lines` - An Iterator with elements representing either a fixed filename
49+
/// or a counter-based template.
50+
pub fn generate_names<'a, T>(lines: &mut T) -> Result<Vec<PathBuf>, Vec<PathGenError<'a>>>
51+
where
52+
T: Iterator<Item = &'a str>,
53+
{
54+
let mut results = Vec::new();
55+
let mut errors = Vec::new();
56+
57+
let mut counters = Vec::new();
58+
59+
for (idx, line) in lines.enumerate() {
60+
match ParsedLine::try_from(line) {
61+
Ok(ParsedLine::Fixed(literal)) => {
62+
results.push(PathBuf::from(literal));
63+
}
64+
Ok(ParsedLine::Countable(template)) => {
65+
if counters.is_empty() {
66+
counters.extend(template.parts().iter().filter_map(|part| match part {
67+
TemplatePart::Text(_) => None,
68+
TemplatePart::CounterBuilder(builder) => Some(builder.build()),
69+
}))
70+
}
71+
72+
if counters.len() != template.counter_count() {
73+
errors.push(PathGenError::MismatchCounters {
74+
expected: counters.len(),
75+
got: template.counter_count(),
76+
line_number: idx + 1,
77+
content: line,
78+
});
79+
continue;
80+
}
81+
82+
let mut out = String::new();
83+
let mut counter_idx = 0;
84+
85+
for part in template.parts() {
86+
match part {
87+
TemplatePart::Text(text) => {
88+
out.push_str(text);
89+
}
90+
TemplatePart::CounterBuilder(builder) => {
91+
let counter = &mut counters[counter_idx];
92+
counter.update_from(*builder);
93+
94+
let _ = counter.write_value(&mut out);
95+
counter.advance();
96+
97+
counter_idx += 1;
98+
}
99+
}
100+
}
101+
102+
results.push(PathBuf::from(out));
103+
}
104+
Err(parse_err) => {
105+
errors.push(PathGenError::ParseError { line_number: idx + 1, error: parse_err });
106+
}
107+
}
108+
}
109+
110+
if errors.is_empty() { Ok(results) } else { Err(errors) }
111+
}
112+
113+
/// Represents errors that can occur during filename generation.
114+
#[derive(Debug, PartialEq, Eq)]
115+
pub enum PathGenError<'a> {
116+
/// Error parsing a line into a valid counter template.
117+
ParseError { line_number: usize, error: ParseError<'a> },
118+
119+
/// Error indicating mismatch between the expected and actual
120+
/// number of counters at some line
121+
MismatchCounters { expected: usize, got: usize, line_number: usize, content: &'a str },
122+
}
123+
124+
impl fmt::Display for PathGenError<'_> {
125+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126+
self.write_to(f, u16::MAX)
127+
}
128+
}
129+
130+
impl Error for PathGenError<'_> {}
131+
132+
impl PathGenError<'_> {
133+
/// Writes a formatted error message to a provided formatter or buffer, taking
134+
/// into account terminal width for enhanced readability.
135+
///
136+
/// This method formats error messages differently depending on the available
137+
/// width of the terminal, trimming and adjusting content to prevent line
138+
/// overflow and maintain clarity.
139+
///
140+
/// # Arguments
141+
///
142+
/// * `out` - A mutable reference to an object implementing the `Write` trait
143+
/// where the formatted error message will be written.
144+
/// * `term_width` - The maximum width of the terminal, which determines how
145+
/// the error message should be formatted.
146+
pub fn write_to(&self, out: &mut impl Write, term_width: u16) -> fmt::Result {
147+
// Calculates the number of digits in a given line number (for formatting
148+
// alignment).
149+
fn print_width(mut n: usize) -> usize {
150+
let mut width = 1;
151+
while n >= 10 {
152+
n /= 10;
153+
width += 1;
154+
}
155+
width
156+
}
157+
158+
match self {
159+
PathGenError::ParseError { line_number, error } => {
160+
let Range { start, end } = error.span;
161+
162+
// Calculate the width needed for the line number.
163+
let line_num_width = print_width(*line_number);
164+
165+
// Calculate the width needed to print the line number and
166+
// separator (e.g., "182| ").
167+
let available = term_width.saturating_sub(line_num_width as u16 + 2) as usize;
168+
169+
// Prepare the input string for display and calculate its full width.
170+
let mut print_input = error.input;
171+
let input_len_full = UnicodeWidthStr::width(print_input);
172+
// Calculate the width of the string before the error start.
173+
let mut input_len_left = UnicodeWidthStr::width(&print_input[..start]);
174+
// Calculate the width of the error span, ensuring at least 1 character.
175+
let input_len_span = UnicodeWidthStr::width(&print_input[start..end]).max(1);
176+
177+
// Constructs a hint string indicating expected and found values, if applicable.
178+
let mut hint = String::new();
179+
let mut hint_len = 0;
180+
181+
if let Some(exp) = &error.expected {
182+
let _ = write!(hint, " Expected: '{}'", exp);
183+
hint_len = UnicodeWidthStr::width(hint.as_str());
184+
}
185+
if let Some(fnd) = &error.found {
186+
let _ = write!(hint, ", found: '{}'", fnd);
187+
hint_len = UnicodeWidthStr::width(hint.as_str());
188+
}
189+
190+
// Determines formatting style based on available width.
191+
let mut could_use_pretty_print = false;
192+
let mut prepend_dots = false;
193+
let mut append_dots = false;
194+
195+
// Check if there's enough space to display the full input string and the error
196+
// details
197+
if available >= input_len_full && available >= input_len_left + input_len_span + hint_len {
198+
could_use_pretty_print = true;
199+
} else if available >= input_len_left + input_len_span + UnicodeWidthStr::width("…")
200+
&& available >= input_len_left + input_len_span + hint_len
201+
{
202+
// The full input doesn't fit, but there's space for the left part, error span,
203+
// ellipsis ("…") and the error details.
204+
205+
// Truncate the input string to the end of the error position
206+
print_input = &print_input[..end];
207+
// Indicate that an ellipsis should be appended to show truncation at the end
208+
append_dots = true;
209+
could_use_pretty_print = true;
210+
} else if available
211+
>= UnicodeWidthStr::width("…") + UnicodeWidthStr::width(&print_input[start..])
212+
&& available >= UnicodeWidthStr::width("…") + input_len_span + hint_len
213+
{
214+
// There's space for an ellipsis, the error span from start, and hint
215+
// Truncate the input string to start at the error position
216+
print_input = &print_input[start..];
217+
// Indicate that an ellipsis should be prepended to show truncation at the start
218+
prepend_dots = true;
219+
220+
// Reset the left length since we're starting from the error position
221+
input_len_left = 0;
222+
could_use_pretty_print = true;
223+
}
224+
225+
// Print the error header
226+
write!(out, "Error: {}", error.reason)?;
227+
228+
if could_use_pretty_print {
229+
// Write a blank line with alignment for the line number.
230+
writeln!(out, "\n{:>offset$}|", "", offset = line_num_width)?;
231+
232+
// Write the line number and input, with optional ellipses.
233+
write!(out, "{}| ", line_number)?;
234+
if prepend_dots {
235+
write!(out, "…")?;
236+
// Adjust the input_len_left since we printed '…'
237+
input_len_left = UnicodeWidthStr::width("…");
238+
}
239+
write!(out, "{}", print_input)?;
240+
if append_dots {
241+
write!(out, "…")?;
242+
}
243+
// Write the caret line indicating the error span and the hint.
244+
writeln!(
245+
out,
246+
"\n{:>num_offset$}| {:>offset$}{:^>length$}{}\n",
247+
"",
248+
"",
249+
"",
250+
hint,
251+
num_offset = line_num_width,
252+
offset = input_len_left,
253+
length = input_len_span
254+
)
255+
} else {
256+
// Fallback: write the hint and the full input line.
257+
writeln!(out, ".{}", hint)?;
258+
writeln!(out, "{}| {}\n", line_number, error.input)
259+
}
260+
}
261+
262+
PathGenError::MismatchCounters { expected, got, line_number, content } => {
263+
// Calculate the width needed for the line number.
264+
let line_num_width = print_width(*line_number);
265+
266+
// Calculate the width needed to print the line number and
267+
// separator (e.g., "182| ").
268+
let available = term_width.saturating_sub(line_num_width as u16 + 2) as usize;
269+
270+
// Calculate the width of the content.
271+
let input_len_span = UnicodeWidthStr::width(*content).max(1);
272+
273+
let hint = format!(" Expected {} counters, but got {}", expected, got);
274+
let hint_len = UnicodeWidthStr::width(hint.as_str());
275+
276+
let mut could_use_pretty_print = false;
277+
278+
if available >= input_len_span + hint_len {
279+
could_use_pretty_print = true;
280+
}
281+
282+
// Print the error header
283+
write!(out, "Error: Mismatch counter numbers")?;
284+
285+
if could_use_pretty_print {
286+
// Write a blank line with alignment.
287+
writeln!(out, "\n{:>offset$}|", "", offset = line_num_width)?;
288+
289+
// Write the line number and content.
290+
writeln!(out, "{}| {}", line_number, content)?;
291+
292+
// Write the caret line spanning the entire content and the hint.
293+
writeln!(
294+
out,
295+
"{:>num_offset$}| {:^>length$}{}\n",
296+
"",
297+
"",
298+
hint,
299+
num_offset = line_num_width,
300+
length = input_len_span
301+
)
302+
} else {
303+
// Fallback: write the hint and the content.
304+
writeln!(out, ".{}", hint)?;
305+
writeln!(out, "{}| {}\n", line_number, content)
306+
}
307+
}
308+
}
309+
}
310+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
use std::path::PathBuf;
2+
3+
use super::*;
4+
5+
// Helper to quickly compare Ok(Vec<PathBuf>)
6+
fn assert_ok_paths(result: Result<Vec<PathBuf>, Vec<PathGenError>>, expected: &[&str]) {
7+
match result {
8+
Ok(paths) => {
9+
let actual: Vec<_> = paths.iter().map(|p| p.to_string_lossy()).collect();
10+
let expected: Vec<_> = expected.iter().copied().map(String::from).collect();
11+
assert_eq!(actual, expected, "Expected {:?}, got {:?}", expected, actual);
12+
}
13+
Err(errs) => panic!("Expected Ok(...), got Err({:?})", errs),
14+
}
15+
}
16+
17+
#[test]
18+
fn test_generate_names_no_counters() {
19+
// All lines are just plain filenames (no counters)
20+
let mut input = "file1.txt\nfile2.txt\nanother_file\n".lines();
21+
let result = generate_names(&mut input);
22+
// Should succeed, returning the same lines as PathBuf
23+
assert_ok_paths(result, &["file1.txt", "file2.txt", "another_file"]);
24+
}
25+
26+
#[test]
27+
fn test_generate_names() {
28+
let input = [
29+
// Start = 1, Step = 1, Width = 1
30+
"file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 1
31+
"file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 1
32+
// Start = 5, Step = 1, Width = 1
33+
"file_%{D,5}_%{d,5}_%{N,5}_%{n,5}_%{A,5}_%{a,5}_%{R,5}_%{r,5}_%{C,5}_%{c,5}.txt", // print 5
34+
"file_%{D }_%{d }_%{N }_%{n }_%{A }_%{a }_%{R }_%{r }_%{C }_%{c }.txt", // print 6
35+
// Start = 5 (two times), Step = 1, Width = 1
36+
"file_%{D,5}_%{d,5}_%{N,5}_%{n,5}_%{A,5}_%{a,5}_%{R,5}_%{r,5}_%{C,5}_%{c,5}.txt", // print 5
37+
"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)
38+
// Start = 5, Step = 3, Width = 1
39+
"file_%{D,_,3}_%{d,_,3}_%{N,_,3}_%{n,_,3}_%{A,_,3}_%{a,_,3}_%{R,_,3}_%{r,_,3}_%{C,_,3}_%{c,_,3}.txt", // print 6
40+
"file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 9
41+
// Start = 5, Step = 3, Width = 3
42+
"file_%{D,_,_,3}_%{d,_,_,3}_%{N,_,_,3}_%{n,_,_,3}_%{A,_,_,3}_%{a,_,_,3}_%{R,_,_,3}_%{r,_,_,3}_%{C,_,_,3}_%{c,_,_,3}.txt", // print 012
43+
"file_%{D}_%{d}_%{N}_%{n}_%{A}_%{a}_%{R}_%{r}_%{C}_%{c}.txt", // print 015
44+
// Change counter formats, Start = 5, Step = 3, Width = 3
45+
"file_%{A}_%{R}_%{C}_%{N}_%{a}_%{r}_%{c}_%{n}_%{D}_%{d}.txt", // print 018
46+
]
47+
.join("\n");
48+
49+
let result = generate_names(&mut input.lines());
50+
assert_ok_paths(
51+
result,
52+
&[
53+
"file_1_1_1_1_A_a_I_i_А_а.txt",
54+
"file_2_2_2_2_B_b_II_ii_Б_б.txt",
55+
"file_5_5_5_5_E_e_V_v_Д_д.txt",
56+
"file_6_6_6_6_F_f_VI_vi_Е_е.txt",
57+
"file_5_5_5_5_E_e_V_v_Д_д.txt",
58+
"file_5_5_5_5_E_e_V_v_Д_д.txt",
59+
"file_6_6_6_6_F_f_VI_vi_Е_е.txt",
60+
"file_9_9_9_9_I_i_IX_ix_И_и.txt",
61+
"file_012_012_012_012_00L_00l_XII_xii_00М_00м.txt",
62+
"file_015_015_015_015_00O_00o_0XV_0xv_00П_00п.txt",
63+
"file_00R_XVIII_00Т_018_00r_xviii_00т_018_018_018.txt",
64+
],
65+
);
66+
}
67+
68+
#[test]
69+
fn test_generate_names_mismatch_counters() {
70+
// First line has 2 counters, second line has 1
71+
let input = "\
72+
file_%{n}_%{a}.txt\n\
73+
file_%{n}.txt\
74+
";
75+
let result = generate_names(&mut input.lines()).unwrap_err();
76+
// Should produce PathGenError::MismatchCounters
77+
assert_eq!(
78+
result,
79+
&[PathGenError::MismatchCounters {
80+
expected: 2,
81+
got: 1,
82+
line_number: 2,
83+
content: "file_%{n}.txt",
84+
}]
85+
);
86+
}
87+
88+
#[test]
89+
fn test_generate_names_parse_errors() {
90+
let input = "\
91+
Ü-Wagen examplé_слово_%{???}.txt\n\
92+
Ü-Wagen examplé_слово_%{n,???}.txt\n\
93+
Ü-Wagen examplé_слово_%{n,1,???}.txt\n\
94+
Ü-Wagen examplé_слово_%{n,1,1,???}.txt\n\
95+
Ü-Wagen examplé_слово_%{n,1,1,1,???}.txt\n\
96+
Ü-Wagen examplé_слово_%{n,1,1,1,}.txt\n\
97+
Ü-Wagen examplé_слово_%{n}.txt\n\
98+
Ü-Wagen examplé_слово_%{n}_%{n}.txt
99+
";
100+
let mut output = String::new();
101+
generate_names(&mut input.lines()).unwrap_err().iter().for_each(|err| {
102+
let _ = write!(output, "{err}");
103+
});
104+
105+
let expected = "\
106+
Error: Unexpected counter kind
107+
|
108+
1| Ü-Wagen examplé_слово_%{???}.txt
109+
| ^^^ Expected: 'one of D, d, N, n, A, a, R, r, C, c', found: '???'
110+
111+
Error: Invalid digit found in string
112+
|
113+
2| Ü-Wagen examplé_слово_%{n,???}.txt
114+
| ^^^ Expected: 'digit', found: '???'
115+
116+
Error: Invalid digit found in string
117+
|
118+
3| Ü-Wagen examplé_слово_%{n,1,???}.txt
119+
| ^^^ Expected: 'digit', found: '???'
120+
121+
Error: Invalid digit found in string
122+
|
123+
4| Ü-Wagen examplé_слово_%{n,1,1,???}.txt
124+
| ^^^ Expected: 'digit', found: '???'
125+
126+
Error: Extra arguments
127+
|
128+
5| Ü-Wagen examplé_слово_%{n,1,1,1,???}.txt
129+
| ^^^^ Expected: 'no additional arguments'
130+
131+
Error: Extra arguments
132+
|
133+
6| Ü-Wagen examplé_слово_%{n,1,1,1,}.txt
134+
| ^ Expected: 'no additional arguments'
135+
136+
Error: Mismatch counter numbers
137+
|
138+
8| Ü-Wagen examplé_слово_%{n}_%{n}.txt
139+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Expected 1 counters, but got 2
140+
141+
";
142+
143+
assert_eq!(output, expected);
144+
}

0 commit comments

Comments
 (0)
Please sign in to comment.