1use crate::models::{BookFolder, BookCase, Config};
4use anyhow::{Context, Result};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone)]
10pub struct OrganizeResult {
11 pub book_name: String,
13 pub source_path: PathBuf,
15 pub destination_path: Option<PathBuf>,
17 pub action: OrganizeAction,
19 pub success: bool,
21 pub error_message: Option<String>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum OrganizeAction {
28 MovedToConvert,
30 MovedToM4B,
32 Skipped,
34 SkippedInvalid,
36}
37
38impl OrganizeAction {
39 pub fn description(&self) -> &'static str {
41 match self {
42 Self::MovedToConvert => "Moved to conversion folder",
43 Self::MovedToM4B => "Moved to M4B folder",
44 Self::Skipped => "Already in correct location",
45 Self::SkippedInvalid => "Skipped (not a valid audiobook)",
46 }
47 }
48}
49
50pub struct Organizer {
52 root: PathBuf,
54 m4b_folder: String,
56 convert_folder: String,
58 dry_run: bool,
60}
61
62impl Organizer {
63 pub fn new(root: PathBuf, config: &Config) -> Self {
65 Self {
66 root,
67 m4b_folder: config.organization.m4b_folder.clone(),
68 convert_folder: config.organization.convert_folder.clone(),
69 dry_run: false,
70 }
71 }
72
73 pub fn with_dry_run(root: PathBuf, config: &Config, dry_run: bool) -> Self {
75 Self {
76 root,
77 m4b_folder: config.organization.m4b_folder.clone(),
78 convert_folder: config.organization.convert_folder.clone(),
79 dry_run,
80 }
81 }
82
83 pub fn organize_book(&self, book: &BookFolder) -> Result<OrganizeResult> {
85 let book_name = book.name.clone();
86 let source_path = book.folder_path.clone();
87
88 let (target_folder_name, action) = match book.case {
90 BookCase::A | BookCase::B | BookCase::E => {
91 (&self.convert_folder, OrganizeAction::MovedToConvert)
93 }
94 BookCase::C => {
95 (&self.m4b_folder, OrganizeAction::MovedToM4B)
97 }
98 BookCase::D => {
99 return Ok(OrganizeResult {
101 book_name,
102 source_path,
103 destination_path: None,
104 action: OrganizeAction::SkippedInvalid,
105 success: true,
106 error_message: None,
107 });
108 }
109 };
110
111 let target_folder = self.root.join(target_folder_name);
112
113 if let Some(parent) = source_path.parent() {
115 if parent == target_folder {
116 return Ok(OrganizeResult {
117 book_name,
118 source_path,
119 destination_path: None,
120 action: OrganizeAction::Skipped,
121 success: true,
122 error_message: None,
123 });
124 }
125 }
126
127 let destination_path = target_folder.join(
129 source_path
130 .file_name()
131 .context("Invalid source path")?,
132 );
133
134 let final_destination = self.resolve_naming_conflict(&destination_path)?;
136
137 if self.dry_run {
139 tracing::info!(
140 "[DRY RUN] Would move: {} -> {}",
141 source_path.display(),
142 final_destination.display()
143 );
144 } else {
145 if !target_folder.exists() {
147 fs::create_dir_all(&target_folder)
148 .with_context(|| format!("Failed to create folder: {}", target_folder.display()))?;
149 }
150
151 fs::rename(&source_path, &final_destination)
153 .with_context(|| {
154 format!(
155 "Failed to move {} to {}",
156 source_path.display(),
157 final_destination.display()
158 )
159 })?;
160
161 tracing::info!(
162 "Moved: {} -> {}",
163 source_path.display(),
164 final_destination.display()
165 );
166 }
167
168 Ok(OrganizeResult {
169 book_name,
170 source_path,
171 destination_path: Some(final_destination),
172 action,
173 success: true,
174 error_message: None,
175 })
176 }
177
178 pub fn organize_batch(&self, books: Vec<BookFolder>) -> Vec<OrganizeResult> {
180 let mut results = Vec::new();
181
182 for book in books {
183 match self.organize_book(&book) {
184 Ok(result) => results.push(result),
185 Err(e) => {
186 tracing::error!("Failed to organize {}: {}", book.name, e);
187 results.push(OrganizeResult {
188 book_name: book.name.clone(),
189 source_path: book.folder_path.clone(),
190 destination_path: None,
191 action: OrganizeAction::Skipped,
192 success: false,
193 error_message: Some(e.to_string()),
194 });
195 }
196 }
197 }
198
199 results
200 }
201
202 fn resolve_naming_conflict(&self, path: &Path) -> Result<PathBuf> {
204 if !path.exists() {
205 return Ok(path.to_path_buf());
206 }
207
208 let parent = path.parent().context("Invalid path")?;
209 let base_name = path
210 .file_name()
211 .and_then(|s| s.to_str())
212 .context("Invalid filename")?;
213
214 for i in 2..=999 {
216 let new_name = format!("{}_{}", base_name, i);
217 let new_path = parent.join(&new_name);
218
219 if !new_path.exists() {
220 tracing::warn!(
221 "Naming conflict: {} -> {}",
222 path.display(),
223 new_path.display()
224 );
225 return Ok(new_path);
226 }
227 }
228
229 anyhow::bail!("Could not resolve naming conflict for {}", path.display())
230 }
231
232 pub fn get_target_folder(&self, case: BookCase) -> Option<PathBuf> {
234 match case {
235 BookCase::A | BookCase::B | BookCase::E => Some(self.root.join(&self.convert_folder)),
236 BookCase::C => Some(self.root.join(&self.m4b_folder)),
237 BookCase::D => None,
238 }
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::models::OrganizationConfig;
246 use tempfile::tempdir;
247
248 fn create_test_config() -> Config {
249 let mut config = Config::default();
250 config.organization = OrganizationConfig {
251 m4b_folder: "M4B".to_string(),
252 convert_folder: "To_Convert".to_string(),
253 };
254 config
255 }
256
257 #[test]
258 fn test_organizer_creation() {
259 let config = create_test_config();
260 let organizer = Organizer::new(PathBuf::from("/tmp"), &config);
261 assert_eq!(organizer.m4b_folder, "M4B");
262 assert_eq!(organizer.convert_folder, "To_Convert");
263 assert!(!organizer.dry_run);
264 }
265
266 #[test]
267 fn test_organizer_dry_run() {
268 let config = create_test_config();
269 let organizer = Organizer::with_dry_run(PathBuf::from("/tmp"), &config, true);
270 assert!(organizer.dry_run);
271 }
272
273 #[test]
274 fn test_organize_action_description() {
275 assert_eq!(
276 OrganizeAction::MovedToConvert.description(),
277 "Moved to conversion folder"
278 );
279 assert_eq!(
280 OrganizeAction::MovedToM4B.description(),
281 "Moved to M4B folder"
282 );
283 assert_eq!(
284 OrganizeAction::Skipped.description(),
285 "Already in correct location"
286 );
287 assert_eq!(
288 OrganizeAction::SkippedInvalid.description(),
289 "Skipped (not a valid audiobook)"
290 );
291 }
292
293 #[test]
294 fn test_get_target_folder() {
295 let config = create_test_config();
296 let organizer = Organizer::new(PathBuf::from("/audiobooks"), &config);
297
298 assert_eq!(
299 organizer.get_target_folder(BookCase::A),
300 Some(PathBuf::from("/audiobooks/To_Convert"))
301 );
302 assert_eq!(
303 organizer.get_target_folder(BookCase::B),
304 Some(PathBuf::from("/audiobooks/To_Convert"))
305 );
306 assert_eq!(
307 organizer.get_target_folder(BookCase::C),
308 Some(PathBuf::from("/audiobooks/M4B"))
309 );
310 assert_eq!(organizer.get_target_folder(BookCase::D), None);
311 }
312
313 #[test]
314 fn test_organize_invalid_book() {
315 let dir = tempdir().unwrap();
316 let config = create_test_config();
317 let organizer = Organizer::new(dir.path().to_path_buf(), &config);
318
319 let mut book = BookFolder::new(dir.path().join("Invalid"));
320 book.case = BookCase::D;
321
322 let result = organizer.organize_book(&book).unwrap();
323 assert!(result.success);
324 assert_eq!(result.action, OrganizeAction::SkippedInvalid);
325 assert!(result.destination_path.is_none());
326 }
327
328 #[test]
329 fn test_organize_batch() {
330 let dir = tempdir().unwrap();
331 let config = create_test_config();
332 let organizer = Organizer::with_dry_run(dir.path().to_path_buf(), &config, true);
333
334 let book1_dir = dir.path().join("Book1");
336 fs::create_dir(&book1_dir).unwrap();
337 let mut book1 = BookFolder::new(book1_dir);
338 book1.case = BookCase::A;
339
340 let book2_dir = dir.path().join("Book2");
341 fs::create_dir(&book2_dir).unwrap();
342 let mut book2 = BookFolder::new(book2_dir);
343 book2.case = BookCase::C;
344
345 let results = organizer.organize_batch(vec![book1, book2]);
346 assert_eq!(results.len(), 2);
347 assert!(results[0].success);
348 assert!(results[1].success);
349 }
350
351 #[test]
352 fn test_resolve_naming_conflict() {
353 let dir = tempdir().unwrap();
354 let config = create_test_config();
355 let organizer = Organizer::new(dir.path().to_path_buf(), &config);
356
357 let existing = dir.path().join("book");
359 fs::create_dir(&existing).unwrap();
360
361 let resolved = organizer.resolve_naming_conflict(&existing).unwrap();
363 assert_eq!(resolved, dir.path().join("book_2"));
364
365 fs::create_dir(&resolved).unwrap();
367 let resolved2 = organizer.resolve_naming_conflict(&existing).unwrap();
368 assert_eq!(resolved2, dir.path().join("book_3"));
369 }
370}