pub fn torrent_filename(title: &str, torrent_url: &str) -> String {
let mut stem = sanitize_stem(title);
if stem.is_empty() {
stem = sanitize_stem(&url_stem(torrent_url));
}
if stem.is_empty() {
stem = "episode".to_string();
}
format!("{stem}.torrent")
}
pub(crate) fn is_unsafe_display_char(c: char) -> bool {
c.is_control()
|| matches!(c,
'\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{2069}' | '\u{2028}' | '\u{2029}' | '\u{061C}' | '\u{FEFF}' )
}
pub(crate) fn strip_unsafe(s: &str) -> String {
s.chars().filter(|c| !is_unsafe_display_char(*c)).collect()
}
pub fn sanitize_stem(title: &str) -> String {
let mapped: String = title
.chars()
.filter(|c| !is_unsafe_display_char(*c))
.map(|c| match c {
'/' => '⁄',
'\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
other => other,
})
.collect();
mapped.trim().trim_end_matches(['.', ' ']).to_string()
}
fn url_stem(url: &str) -> String {
let last = url
.rsplit('/')
.next()
.unwrap_or("")
.trim_end_matches(".torrent");
if last.is_empty() || last.contains(':') {
"".to_string()
} else {
last.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
const URL: &str = "https://mikanani.me/Download/20260628/b66771fd95710ab32a19a4797994196780c94527.torrent";
#[test]
fn keeps_cjk_brackets_and_fullwidth_colon() {
assert_eq!(
torrent_filename("[猎户压制部] Dr.STONE:Science Future [37] [1080p]", URL),
"[猎户压制部] Dr.STONE:Science Future [37] [1080p].torrent"
);
}
#[test]
fn replaces_slash_with_fraction_slash() {
assert_eq!(torrent_filename("新石纪 / Dr.STONE [37]", URL), "新石纪 ⁄ Dr.STONE [37].torrent");
}
#[test]
fn replaces_ascii_windows_illegal_chars() {
assert_eq!(
torrent_filename(r#"a\b:c*d?e"f<g>h|i"#, URL),
"a_b_c_d_e_f_g_h_i.torrent"
);
}
#[test]
fn strips_control_chars() {
assert_eq!(torrent_filename("a\u{0}b\tc\u{7f}d", URL), "abcd.torrent");
}
#[test]
fn trims_whitespace_and_trailing_dots() {
assert_eq!(torrent_filename(" name.. ", URL), "name.torrent");
}
#[test]
fn strips_bidi_and_zero_width_chars() {
assert_eq!(
torrent_filename("ab\u{202E}cd\u{200B}ef\u{feff}", URL),
"abcdef.torrent"
);
}
#[test]
fn strip_unsafe_drops_format_and_separator_chars_but_keeps_cjk() {
assert_eq!(strip_unsafe("a\u{202e}b\u{2028}c\u{feff}d"), "abcd");
assert_eq!(strip_unsafe("石纪元 : Dr.STONE"), "石纪元 : Dr.STONE");
}
#[test]
fn empty_title_falls_back_to_url_hash() {
assert_eq!(
torrent_filename("...", URL),
"b66771fd95710ab32a19a4797994196780c94527.torrent"
);
}
#[test]
fn empty_title_and_useless_url_falls_back_to_episode() {
assert_eq!(torrent_filename("...", "https://mikanani.me/"), "episode.torrent");
}
#[test]
fn empty_title_with_url_having_illegal_chars_in_filename() {
assert_eq!(torrent_filename("...", "https://x/file?x.torrent"), "file_x.torrent");
}
}