//! 导出收尾(finalize)与后处理。 //! //! 包括写入最终文件、自动打开产物等"完成后"逻辑。 //! 具体子模块:`finalize_epub`、`html_utils`、`image_utils`、`segment_comments`、`segment_shared`。 use std::fs::{self, File}; use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::{collections::HashMap, collections::HashSet}; use serde_json::Value; use tracing::{error, info, warn}; use super::parser::ContentParser; use crossterm::event::EnableMouseCapture; use crossterm::terminal::enable_raw_mode; use super::audio_generator::generate_audiobook; use super::book_manager::BookManager; use super::finalize_epub::finalize_epub; use super::finalize_pdf::finalize_pdf; use crate::base_system::context::safe_fs_name; use crate::download::downloader; /// 生成最终输出;返回是否需要延迟清理缓存。 pub fn run_finalize( manager: &mut BookManager, chapters: &[Value], _result: i32, directory_raw: Option<&Value>, reporter: Option<&mut crate::download::downloader::ProgressReporter>, cancel: Option<&std::sync::Arc>, ) -> bool { info!(target: "book_manager", "finalize start: chapters={}", chapters.len()); let mut reporter = reporter; // "下载完后选择":在生成文件前询问用户使用哪个书名(仅 CLI 模式兜底) if manager.config.is_ask_after_download() && !manager.book_name_selected_after_download && let Some(chosen) = prompt_book_name_selection(manager) { info!(target: "book_manager", "用户选择书名: {}", chosen); let old_name = manager.book_name.clone(); manager.book_name = chosen; manager.book_name_selected_after_download = true; if old_name != manager.book_name { manager.remember_previous_book_name(&old_name); } } // "下载完后选择格式":在生成文件前询问用户选择输出格式(仅 CLI 模式兜底) if manager.config.ask_format_after_download && !manager.format_selected_after_download && let Some(chosen) = prompt_format_selection(manager) { info!(target: "book_manager", "用户选择输出格式: {}", chosen); if let Err(err) = manager.config.apply_output_format_choice(&chosen) { warn!(target: "book_manager", error = %err, "应用输出格式选择失败"); } manager.format_selected_after_download = true; } let fmt = manager.config.novel_format.to_lowercase(); let output_path = match prepare_output_path(manager, &fmt) { Ok(p) => p, Err(e) => { error!(target: "book_manager", error = ?e, "prepare output path failed"); return false; } }; let result: anyhow::Result<()> = if fmt == "txt" { finalize_txt(manager, chapters, &output_path, directory_raw) } else if fmt == "pdf" { finalize_pdf(manager, chapters, &output_path, directory_raw) } else { let reporter_ref = { #[allow(clippy::needless_option_as_deref)] reporter.as_deref_mut() }; finalize_epub(manager, chapters, &output_path, directory_raw, reporter_ref) }; if let Err(e) = result { error!(target: "book_manager", error = ?e, "finalize failed"); return false; } archive_previous_main_outputs(manager, &output_path); info!(target: "book_manager", "written: {}", output_path.display()); if manager.config.auto_open_downloaded_files { if let Err(e) = open_in_default_app(&output_path) { warn!(target: "book_manager", error = ?e, "auto open downloaded file failed"); } // Best-effort: re-assert TUI terminal modes after spawning external opener. if reporter.as_ref().is_some_and(|r| r.has_ui_callback()) { let _ = enable_raw_mode(); let mut out = std::io::stdout(); let _ = crossterm::execute!(&mut out, EnableMouseCapture); } } let audiobook_bar = reporter.as_ref().and_then(|r| r.cli_save_bar()); let quiet = reporter.as_ref().is_some_and(|r| r.has_ui_callback()); let reporter_ref = { #[allow(clippy::needless_option_as_deref)] reporter.as_deref_mut() }; let audiobook_ok = generate_audiobook( manager, chapters, audiobook_bar.as_ref(), quiet, reporter_ref, cancel, ); if !audiobook_ok { warn!(target: "book_manager", "audiobook generation failed"); } else if manager.config.enable_audiobook { archive_previous_audiobook_outputs(manager); } true } fn open_in_default_app(path: &Path) -> std::io::Result<()> { if cfg!(target_os = "windows") { Command::new("explorer").arg(path).spawn()?; return Ok(()); } if cfg!(target_os = "macos") { Command::new("open").arg(path).spawn()?; return Ok(()); } Command::new("xdg-open").arg(path).spawn()?; Ok(()) } fn prepare_output_path(manager: &BookManager, fmt: &str) -> std::io::Result { let raw_name = if manager.book_name.is_empty() { "book" } else { manager.book_name.as_str() }; let safe_book = safe_fs_name(raw_name, "_", 120); let dir = manager.default_save_dir(); std::fs::create_dir_all(&dir)?; // bulk_files: TXT 每章一个文件,输出到"小说名"文件夹 if fmt == "txt" && manager.config.bulk_files { return Ok(dir.join(&safe_book)); } let suffix = match fmt { "epub" => "epub", "pdf" => "pdf", _ => "txt", }; let output_path = dir.join(format!("{}.{}", safe_book, suffix)); // 检查文件是否已存在且不允许覆盖 if !manager.config.allow_overwrite_files && output_path.exists() { return Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, format!("文件已存在且配置禁止覆盖: {}", output_path.display()), )); } Ok(output_path) } fn archive_previous_main_outputs(manager: &BookManager, output_path: &Path) { if manager.previous_book_names.is_empty() { return; } let Some(dir) = output_path.parent() else { return; }; let current_name = output_path .file_name() .and_then(|s| s.to_str()) .unwrap_or(""); let current_base = output_path .file_stem() .and_then(|s| s.to_str()) .unwrap_or(current_name); let output_path = output_path.to_path_buf(); let mut seen = HashSet::new(); for old_name in &manager.previous_book_names { let safe_old = safe_fs_name(old_name, "_", 120); if safe_old.is_empty() || safe_old == current_base || !seen.insert(safe_old.clone()) { continue; } let candidates = [ (dir.join(format!("{safe_old}.epub")), "旧 EPUB"), (dir.join(format!("{safe_old}.txt")), "旧 TXT"), (dir.join(format!("{safe_old}.pdf")), "旧 PDF"), (dir.join(&safe_old), "旧 bulk TXT 目录"), ]; for (candidate, label) in candidates { if candidate == output_path || (!candidate.is_file() && !candidate.is_dir()) { continue; } archive_candidate(dir, &candidate, label); } } } fn archive_previous_audiobook_outputs(manager: &BookManager) { if manager.previous_book_names.is_empty() { return; } let dir = manager.default_save_dir(); let current_name = if manager.book_name.trim().is_empty() { manager.book_id.as_str() } else { manager.book_name.as_str() }; let current_safe = safe_fs_name(current_name, "_", 120); let current_audio_dir = dir.join(format!("{current_safe}_audio")); let mut seen = HashSet::new(); for old_name in &manager.previous_book_names { let safe_old = safe_fs_name(old_name, "_", 120); if safe_old.is_empty() || safe_old == current_safe || !seen.insert(safe_old.clone()) { continue; } let candidate = dir.join(format!("{safe_old}_audio")); if candidate == current_audio_dir || !candidate.is_dir() { continue; } archive_candidate(&dir, &candidate, "旧有声书目录"); } } fn archive_candidate(base_dir: &Path, candidate: &Path, label: &str) { let olds_dir = base_dir.join("olds"); if candidate == olds_dir { return; } if let Err(e) = fs::create_dir_all(&olds_dir) { warn!(target: "book_manager", error = ?e, dir = %olds_dir.display(), "创建 olds 目录失败,跳过旧输出归档"); return; } let archive_path = unique_archive_path(&olds_dir, candidate); match fs::rename(candidate, &archive_path) { Ok(_) => info!( target: "book_manager", kind = label, old = %candidate.display(), archived = %archive_path.display(), "检测到书名变化,已归档旧输出" ), Err(e) => warn!( target: "book_manager", kind = label, error = ?e, old = %candidate.display(), archived = %archive_path.display(), "旧输出归档失败" ), } } fn unique_archive_path(olds_dir: &Path, src: &Path) -> PathBuf { let file_name = src .file_name() .and_then(|s| s.to_str()) .unwrap_or("old.epub"); let first = olds_dir.join(file_name); if !first.exists() { return first; } let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("old"); let ext = src.extension().and_then(|s| s.to_str()); for idx in 1.. { let candidate = match ext { Some(ext) if !ext.is_empty() => olds_dir.join(format!("{stem}_{idx}.{ext}")), _ => olds_dir.join(format!("{stem}_{idx}")), }; if !candidate.exists() { return candidate; } } unreachable!() } fn finalize_txt( manager: &BookManager, chapters: &[Value], path: &Path, directory_raw: Option<&Value>, ) -> anyhow::Result<()> { let volume_title_by_chapter_id = volume_title_map_for_chapters(chapters, directory_raw); if manager.config.bulk_files { std::fs::create_dir_all(path)?; let mut inserted_volumes: HashSet = HashSet::new(); // 书籍信息 let mut meta = File::create(path.join("0000_书籍信息.txt"))?; writeln!(meta, "书名:{}", manager.book_name)?; if !manager.author.trim().is_empty() { writeln!(meta, "作者:{}", manager.author)?; } writeln!(meta, "book_id={}", manager.book_id)?; let status_text = match manager.finished { Some(true) => "完结", Some(false) => "连载", None => "未知", }; writeln!(meta, "状态:{}", status_text)?; if let Some(score) = manager.score { writeln!(meta, "评分:{:.1}", score)?; } if let Some(word_count) = manager.word_count { writeln!(meta, "字数:{}", word_count)?; } if let Some(chapter_count) = manager.chapter_count { writeln!(meta, "章节:{}", chapter_count)?; } if let Some(category) = manager.category.as_deref() && !category.trim().is_empty() { writeln!(meta, "分类:{}", category.trim())?; } if !manager.tags.trim().is_empty() { writeln!(meta, "标签:{}", manager.tags)?; } if let Some(read_count_text) = manager.read_count_text.as_deref() && !read_count_text.trim().is_empty() { writeln!(meta, "在读:{}", read_count_text.trim())?; } if !manager.description.trim().is_empty() { writeln!(meta)?; writeln!(meta, "简介:")?; writeln!(meta, "{}", manager.description.trim())?; } // 章节拆分 let width = chapters.len().to_string().len().max(4); for (idx, ch) in chapters.iter().enumerate() { let chapter_id = ch.get("id").and_then(|v| v.as_str()).unwrap_or(""); let title = ch.get("title").and_then(|v| v.as_str()).unwrap_or("章节"); let content = ch.get("content").and_then(|v| v.as_str()).unwrap_or(""); // 缓存为 XHTML,写入 txt 时实时清洗为纯文本 let content = ContentParser::clean_plain(content, title); let safe_title = safe_fs_name(title, "_", 120); let filename = format!( "{num:0width$}_{title}.txt", num = idx + 1, width = width, title = safe_title ); let mut f = File::create(path.join(filename))?; if let Some(vol) = volume_title_by_chapter_id.get(chapter_id) && !vol.trim().is_empty() && inserted_volumes.insert(vol.trim().to_string()) { writeln!(f, "分卷:{}", vol.trim())?; writeln!(f)?; } writeln!(f, "{}", title)?; writeln!(f)?; writeln!(f, "{}", content.trim_end())?; } return Ok(()); } let mut f = File::create(path)?; writeln!(f, "书名:{}", manager.book_name)?; if !manager.author.trim().is_empty() { writeln!(f, "作者:{}", manager.author)?; } writeln!(f, "book_id={}", manager.book_id)?; let status_text = match manager.finished { Some(true) => "完结", Some(false) => "连载", None => "未知", }; writeln!(f, "状态:{}", status_text)?; if let Some(score) = manager.score { writeln!(f, "评分:{:.1}", score)?; } if let Some(word_count) = manager.word_count { writeln!(f, "字数:{}", word_count)?; } if let Some(chapter_count) = manager.chapter_count { writeln!(f, "章节:{}", chapter_count)?; } if let Some(category) = manager.category.as_deref() && !category.trim().is_empty() { writeln!(f, "分类:{}", category.trim())?; } if !manager.tags.trim().is_empty() { writeln!(f, "标签:{}", manager.tags)?; } if let Some(read_count_text) = manager.read_count_text.as_deref() && !read_count_text.trim().is_empty() { writeln!(f, "在读:{}", read_count_text.trim())?; } if !manager.description.trim().is_empty() { writeln!(f)?; writeln!(f, "简介:")?; writeln!(f, "{}", manager.description.trim())?; } writeln!(f)?; writeln!(f, "{}", "=".repeat(40))?; writeln!(f)?; let mut last_volume: Option = None; for ch in chapters { let chapter_id = ch.get("id").and_then(|v| v.as_str()).unwrap_or(""); let title = ch.get("title").and_then(|v| v.as_str()).unwrap_or("章节"); let content = ch.get("content").and_then(|v| v.as_str()).unwrap_or(""); // 缓存为 XHTML,写入 txt 时实时清洗为纯文本 let content = ContentParser::clean_plain(content, title); if let Some(vol) = volume_title_by_chapter_id.get(chapter_id) && !vol.trim().is_empty() && last_volume.as_deref() != Some(vol.trim()) { writeln!(f, "【{}】\n", vol.trim())?; last_volume = Some(vol.trim().to_string()); } writeln!(f, "{}\n", title)?; writeln!(f, "{}\n", content.trim_end())?; writeln!(f, "\n----------------------------------------\n")?; } Ok(()) } pub(super) fn volume_title_map_for_chapters( chapters: &[Value], directory_raw: Option<&Value>, ) -> HashMap { let Some(raw) = directory_raw else { return HashMap::new(); }; let known_chapter_ids: HashSet = chapters .iter() .filter_map(|ch| ch.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) .collect(); if known_chapter_ids.is_empty() { return HashMap::new(); } let volumes = extract_volume_to_chapter_ids(raw, &known_chapter_ids); let mut volume_order: Vec = Vec::new(); let mut volume_title_by_chapter_id: HashMap = HashMap::new(); for (title, ids) in &volumes { let t = title.trim(); if t.is_empty() || ids.is_empty() { continue; } if !volume_order.contains(&t.to_string()) { volume_order.push(t.to_string()); } for id in ids { volume_title_by_chapter_id .entry(id.clone()) .or_insert_with(|| t.to_string()); } } let skip_default_single = volume_order.len() == 1 && is_default_volume_name(volume_order[0].as_str()); if skip_default_single { return HashMap::new(); } volume_title_by_chapter_id } fn is_default_volume_name(name: &str) -> bool { let s = name.trim(); if s.is_empty() { return true; } let normalized: String = s .chars() .map(|c| match c { ':' | ':' | '—' | '–' | '_' | '·' | '|' | '|' => ' ', _ => c, }) .collect(); let normalized = normalized.split_whitespace().collect::>().join(" "); let lower = normalized.to_lowercase(); let exact = [ "默认", "默认卷", "默认分卷", "第一卷", "第1卷", "卷一", "卷1", "第一卷 默认", "第1卷 默认", ]; for e in exact { if lower == e { return true; } } let lower_ascii = lower.replace(['.', '_'], " "); let lower_ascii = lower_ascii.trim(); if lower_ascii == "volume 1" || lower_ascii == "vol 1" || lower_ascii == "vol1" { return true; } false } fn extract_volume_to_chapter_ids( directory_raw: &Value, known_chapter_ids: &HashSet, ) -> Vec<(String, Vec)> { fn pick_string_or_number(v: Option<&Value>) -> Option { match v { Some(Value::String(s)) => { let t = s.trim(); if t.is_empty() { None } else { Some(t.to_string()) } } Some(Value::Number(n)) => Some(n.to_string()), _ => None, } } fn pick_volume_title(obj: &serde_json::Map) -> Option { let candidates = [ "volume_title", "volume_name", "section_title", "section_name", "group_title", "group_name", ]; for k in candidates { if let Some(Value::String(s)) = obj.get(k) { let t = s.trim(); if !t.is_empty() { return Some(t.to_string()); } } } None } fn pick_title(obj: &serde_json::Map) -> Option { let candidates = [ "volume_title", "volume_name", "catalog_name", "catalog_title", "section_title", "section_name", "group_title", "group_name", "title", "name", ]; for k in candidates { if let Some(Value::String(s)) = obj.get(k) { let t = s.trim(); if !t.is_empty() { return Some(t.to_string()); } } } None } fn looks_like_chapter_obj(obj: &serde_json::Map) -> Option { let id = pick_string_or_number( obj.get("chapter_id") .or_else(|| obj.get("item_id")) .or_else(|| obj.get("catalog_id")) .or_else(|| obj.get("id")), )?; Some(id) } fn push_chapter( title: &str, chapter_id: &str, known_chapter_ids: &HashSet, idx_by_title: &mut HashMap, out: &mut Vec<(String, Vec)>, ) { let t = title.trim(); if t.is_empty() { return; } if !known_chapter_ids.contains(chapter_id) { return; } let i = if let Some(i) = idx_by_title.get(t) { *i } else { let i = out.len(); out.push((t.to_string(), Vec::new())); idx_by_title.insert(t.to_string(), i); i }; let ids = &mut out[i].1; if ids.last().is_some_and(|last| last == chapter_id) { return; } if !ids.contains(&chapter_id.to_string()) { ids.push(chapter_id.to_string()); } } fn visit( v: &Value, current_volume: Option<&str>, known_chapter_ids: &HashSet, idx_by_title: &mut HashMap, out: &mut Vec<(String, Vec)>, ) { match v { Value::Array(arr) => { for it in arr { visit(it, current_volume, known_chapter_ids, idx_by_title, out); } } Value::Object(obj) => { let is_chapter = looks_like_chapter_obj(obj).is_some(); if let Some(vol) = pick_volume_title(obj) && let Some(id) = looks_like_chapter_obj(obj) { push_chapter(&vol, &id, known_chapter_ids, idx_by_title, out); } if let Some(vol) = current_volume && let Some(id) = looks_like_chapter_obj(obj) { push_chapter(vol, &id, known_chapter_ids, idx_by_title, out); } let title_here = if is_chapter { pick_volume_title(obj) } else { pick_title(obj) }; let next_volume = title_here.as_deref().or(current_volume); let child_keys = [ "catalog_data", "item_data_list", "items", "item_list", "children", "child_list", "sub_items", "sub_item_list", "chapter_list", "chapters", "chapter_ids", ]; for k in child_keys { if let Some(arr) = obj.get(k).and_then(Value::as_array) { for child in arr { if let (Some(vol), Some(id)) = (next_volume, pick_string_or_number(Some(child))) { push_chapter(vol, &id, known_chapter_ids, idx_by_title, out); } else { visit(child, next_volume, known_chapter_ids, idx_by_title, out); } } } } for (k, vv) in obj { if child_keys.contains(&k.as_str()) { continue; } visit(vv, next_volume, known_chapter_ids, idx_by_title, out); } } _ => {} } } let mut out: Vec<(String, Vec)> = Vec::new(); let mut idx_by_title: HashMap = HashMap::new(); if let Some(items) = directory_raw .get("item_data_list") .and_then(Value::as_array) { let mut current: Option = None; for it in items { let Some(obj) = it.as_object() else { continue; }; if let Some(vol) = pick_volume_title(obj) { current = Some(vol); } let Some(id) = looks_like_chapter_obj(obj) else { continue; }; if let Some(vol) = current.as_deref() { push_chapter(vol, &id, known_chapter_ids, &mut idx_by_title, &mut out); } } if !out.is_empty() { return out; } } visit( directory_raw, None, known_chapter_ids, &mut idx_by_title, &mut out, ); out } /// 下载完后让用户选择使用哪个书名(仅 CLI 模式)。 /// /// 从 `BookManager` 收集所有可用的书名变体,展示编号让用户选择。 /// 返回 `Some(chosen_name)` 表示用户选了新名字,`None` 表示保持默认。 fn prompt_book_name_selection(manager: &BookManager) -> Option { // 收集可用的书名选项(去重) let mut options: Vec<(&str, String)> = Vec::new(); let default_name = &manager.book_name; options.push(("默认书名", default_name.clone())); if let Some(orig) = &manager.original_book_name && !orig.is_empty() && orig != default_name { options.push(("原始书名", orig.clone())); } if let Some(short) = &manager.book_short_name && !short.is_empty() && short != default_name { // 也和 original_book_name 去重 let dup = manager .original_book_name .as_ref() .is_some_and(|o| o == short); if !dup { options.push(("短书名", short.clone())); } } // 如果只有一个选项就不需要询问 if options.len() <= 1 { return None; } println!("\n=== 选择书名 ==="); for (idx, (label, name)) in options.iter().enumerate() { let marker = if idx == 0 { " (当前)" } else { "" }; println!(" {}. {}: {}{}", idx + 1, label, name, marker); } print!("请选择 [1]: "); io::stdout().flush().ok(); let mut line = String::new(); if io::stdin().lock().read_line(&mut line).is_err() { return None; } let choice = line.trim(); let idx = if choice.is_empty() { 1 } else { match choice.parse::() { Ok(i) if i >= 1 && i <= options.len() => i, _ => return None, } }; let chosen = options[idx - 1].1.clone(); if chosen == *default_name { return None; } Some(chosen) } /// CLI 模式下询问用户选择输出格式。 /// 返回对应的格式值,`None` 表示保持默认。 fn prompt_format_selection(manager: &BookManager) -> Option { let current = manager.config.configured_output_format_choice(); let options = downloader::collect_output_format_options(); println!("\n=== 选择输出格式 ==="); for (idx, opt) in options.iter().enumerate() { let marker = if opt.value == current { " (当前)" } else { "" }; println!(" {}. {}{}", idx + 1, opt.label, marker); } let default_idx = options .iter() .position(|opt| opt.value == current) .unwrap_or(0) + 1; print!("请选择 [{}]: ", default_idx); io::stdout().flush().ok(); let mut line = String::new(); if io::stdin().lock().read_line(&mut line).is_err() { return None; } let choice = line.trim(); let idx = if choice.is_empty() { default_idx } else { match choice.parse::() { Ok(i) if i >= 1 && i <= options.len() => i, _ => return None, } }; let chosen = options[idx - 1].value.clone(); if chosen == current { return None; } Some(chosen) } #[cfg(test)] mod tests { use super::{ archive_previous_audiobook_outputs, archive_previous_main_outputs, prepare_output_path, }; use crate::base_system::context::Config; use crate::book_parser::book_manager::BookManager; #[test] fn archive_previous_named_outputs_when_book_name_changes() { let temp_dir = tempfile::tempdir().unwrap(); let mut config = Config::default(); config.save_path = temp_dir.path().display().to_string(); config.novel_format = "epub".to_string(); let mut manager = BookManager::new(config, "123", "新书名").unwrap(); manager.book_id = "123".to_string(); manager.book_name = "新书名".to_string(); manager.remember_previous_book_name("旧书名"); let old_epub = temp_dir.path().join("旧书名.epub"); let old_txt = temp_dir.path().join("旧书名.txt"); let old_pdf = temp_dir.path().join("旧书名.pdf"); let old_bulk = temp_dir.path().join("旧书名"); std::fs::write(&old_epub, b"old").unwrap(); std::fs::write(&old_txt, b"old txt").unwrap(); std::fs::write(&old_pdf, b"old pdf").unwrap(); std::fs::create_dir_all(&old_bulk).unwrap(); std::fs::write(old_bulk.join("0001.txt"), b"old bulk").unwrap(); let output_path = prepare_output_path(&manager, "epub").unwrap(); assert_eq!(output_path, temp_dir.path().join("新书名.epub")); std::fs::write(&output_path, b"new").unwrap(); archive_previous_main_outputs(&manager, &output_path); assert!(output_path.exists()); assert!(!old_epub.exists()); assert!(!old_txt.exists()); assert!(!old_pdf.exists()); assert!(!old_bulk.exists()); assert!(temp_dir.path().join("olds").join("旧书名.epub").exists()); assert!(temp_dir.path().join("olds").join("旧书名.txt").exists()); assert!(temp_dir.path().join("olds").join("旧书名.pdf").exists()); assert!(temp_dir.path().join("olds").join("旧书名").exists()); } #[test] fn keep_same_name_outputs_on_normal_overwrite_path() { let temp_dir = tempfile::tempdir().unwrap(); let mut config = Config::default(); config.save_path = temp_dir.path().display().to_string(); config.novel_format = "epub".to_string(); let mut manager = BookManager::new(config, "123", "同书名").unwrap(); manager.book_id = "123".to_string(); manager.book_name = "同书名".to_string(); manager.remember_previous_book_name("同书名"); let output_path = prepare_output_path(&manager, "epub").unwrap(); std::fs::write(&output_path, b"new").unwrap(); archive_previous_main_outputs(&manager, &output_path); assert!(output_path.exists()); assert!(!temp_dir.path().join("olds").exists()); } #[test] fn archive_previous_audiobook_dir_when_book_name_changes() { let temp_dir = tempfile::tempdir().unwrap(); let mut config = Config::default(); config.save_path = temp_dir.path().display().to_string(); config.enable_audiobook = true; let mut manager = BookManager::new(config, "123", "新书名").unwrap(); manager.book_id = "123".to_string(); manager.book_name = "新书名".to_string(); manager.remember_previous_book_name("旧书名"); let old_audio = temp_dir.path().join("旧书名_audio"); let current_audio = temp_dir.path().join("新书名_audio"); std::fs::create_dir_all(&old_audio).unwrap(); std::fs::write(old_audio.join("0001.mp3"), b"old audio").unwrap(); std::fs::create_dir_all(¤t_audio).unwrap(); archive_previous_audiobook_outputs(&manager); assert!(current_audio.exists()); assert!(!old_audio.exists()); assert!(temp_dir.path().join("olds").join("旧书名_audio").exists()); } }