audiobook_forge/core/
organizer.rs

1//! Folder organization for audiobooks
2
3use crate::models::{BookFolder, BookCase, Config};
4use anyhow::{Context, Result};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Result of an organization operation
9#[derive(Debug, Clone)]
10pub struct OrganizeResult {
11    /// Book name
12    pub book_name: String,
13    /// Source path
14    pub source_path: PathBuf,
15    /// Destination path (None if no action taken)
16    pub destination_path: Option<PathBuf>,
17    /// Action taken
18    pub action: OrganizeAction,
19    /// Success flag
20    pub success: bool,
21    /// Error message (if failed)
22    pub error_message: Option<String>,
23}
24
25/// Type of organization action
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum OrganizeAction {
28    /// Moved to conversion folder
29    MovedToConvert,
30    /// Moved to M4B folder
31    MovedToM4B,
32    /// Skipped (already in correct location)
33    Skipped,
34    /// Skipped (Case D - not a valid audiobook)
35    SkippedInvalid,
36}
37
38impl OrganizeAction {
39    /// Get human-readable description
40    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
50/// Organizer for managing audiobook folder structure
51pub struct Organizer {
52    /// Root directory
53    root: PathBuf,
54    /// M4B folder name
55    m4b_folder: String,
56    /// Conversion folder name
57    convert_folder: String,
58    /// Dry run mode (don't actually move files)
59    dry_run: bool,
60}
61
62impl Organizer {
63    /// Create a new organizer
64    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    /// Create organizer with dry run mode
74    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    /// Organize a single book folder
84    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        // Determine target folder based on book case
89        let (target_folder_name, action) = match book.case {
90            BookCase::A | BookCase::B => {
91                // Needs conversion
92                (&self.convert_folder, OrganizeAction::MovedToConvert)
93            }
94            BookCase::C => {
95                // Already M4B
96                (&self.m4b_folder, OrganizeAction::MovedToM4B)
97            }
98            BookCase::D => {
99                // Invalid audiobook - skip
100                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        // Check if already in target folder
114        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        // Determine destination path
128        let destination_path = target_folder.join(
129            source_path
130                .file_name()
131                .context("Invalid source path")?,
132        );
133
134        // Handle naming conflicts
135        let final_destination = self.resolve_naming_conflict(&destination_path)?;
136
137        // Execute move (or simulate in dry run mode)
138        if self.dry_run {
139            tracing::info!(
140                "[DRY RUN] Would move: {} -> {}",
141                source_path.display(),
142                final_destination.display()
143            );
144        } else {
145            // Create target folder if it doesn't exist
146            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            // Move the folder
152            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    /// Organize multiple books
179    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    /// Resolve naming conflicts by appending numbers
203    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        // Try appending numbers until we find an available name
215        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    /// Get target folder path for a book case
233    pub fn get_target_folder(&self, case: BookCase) -> Option<PathBuf> {
234        match case {
235            BookCase::A | BookCase::B => 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        // Create test books
335        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        // Create existing file
358        let existing = dir.path().join("book");
359        fs::create_dir(&existing).unwrap();
360
361        // Resolve conflict
362        let resolved = organizer.resolve_naming_conflict(&existing).unwrap();
363        assert_eq!(resolved, dir.path().join("book_2"));
364
365        // Create book_2, resolve again
366        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}