Phase 2: Modèles et Stockage
991224e parent: 831c616 modified
Cargo.lock +2 -0 | @@ -2639,6 +2639,8 @@ dependencies = [ | ||
| 2639 | 2639 | "rss", |
| 2640 | 2640 | "serde", |
| 2641 | 2641 | "serde_json", |
| 2642 | + "tempfile", | |
| 2643 | + "thiserror", | |
| 2642 | 2644 | "tokio", |
| 2643 | 2645 | "uuid", |
| 2644 | 2646 | "winapi", |
| @@ -2639,6 +2639,8 @@ dependencies = [ | |||
| 2639 | "rss", | 2639 | "rss", |
| 2640 | "serde", | 2640 | "serde", |
| 2641 | "serde_json", | 2641 | "serde_json", |
| 2642 | + "tempfile", | ||
| 2643 | + "thiserror", | ||
| 2642 | "tokio", | 2644 | "tokio", |
| 2643 | "uuid", | 2645 | "uuid", |
| 2644 | "winapi", | 2646 | "winapi", |
modified
Cargo.toml +6 -0 | @@ -37,6 +37,12 @@ uuid = { version = "1.0", features = ["v4", "serde"] } | ||
| 37 | 37 | # Path handling |
| 38 | 38 | pathdiff = "0.2" |
| 39 | 39 | |
| 40 | +# Error handling | |
| 41 | +thiserror = "1.0" | |
| 42 | + | |
| 43 | +# Testing utilities | |
| 44 | +tempfile = "3.0" | |
| 45 | + | |
| 40 | 46 | # Windows API compatibility for eframe 0.24 |
| 41 | 47 | [target.'cfg(windows)'.dependencies] |
| 42 | 48 | winapi = { version = "0.3", features = ["winuser", "windef"] } |
| @@ -37,6 +37,12 @@ uuid = { version = "1.0", features = ["v4", "serde"] } | |||
| 37 | # Path handling | 37 | # Path handling |
| 38 | pathdiff = "0.2" | 38 | pathdiff = "0.2" |
| 39 | 39 | ||
| 40 | +# Error handling | ||
| 41 | +thiserror = "1.0" | ||
| 42 | + | ||
| 43 | +# Testing utilities | ||
| 44 | +tempfile = "3.0" | ||
| 45 | + | ||
| 40 | # Windows API compatibility for eframe 0.24 | 46 | # Windows API compatibility for eframe 0.24 |
| 41 | [target.'cfg(windows)'.dependencies] | 47 | [target.'cfg(windows)'.dependencies] |
| 42 | winapi = { version = "0.3", features = ["winuser", "windef"] } | 48 | winapi = { version = "0.3", features = ["winuser", "windef"] } |
modified
spec/init-tasks.md +14 -14 | @@ -42,18 +42,18 @@ | ||
| 42 | 42 | |
| 43 | 43 | | ID | Tâche | Priorité | Durée | Fichiers | Dépendances | Statut | |
| 44 | 44 | |----|-------|----------|-------|---------|-------------|--------| |
| 45 | -| T2.1 | Créer `config/feed.rs` avec la structure `Feed` | H | 1h | `src/config/feed.rs` | T1.3 | ⬜ | | |
| 46 | -| T2.2 | Implémenter `Default`, `Debug`, `Clone`, `Serialize`, `Deserialize` pour `Feed` | H | 1h | `src/config/feed.rs` | T2.1 | ⬜ | | |
| 47 | -| T2.3 | Créer `config/episode.rs` avec la structure `Episode` | H | 1h | `src/config/episode.rs` | T1.3 | ⬜ | | |
| 48 | -| T2.4 | Créer `config/app_state.rs` avec la structure `AppState` | H | 1h | `src/config/app_state.rs` | T1.3 | ⬜ | | |
| 49 | -| T2.5 | Mettre à jour `config/mod.rs` pour exporter les modules | H | 0.5h | `src/config/mod.rs` | T2.1, T2.3, T2.4 | ⬜ | | |
| 50 | -| T2.6 | Créer `config/storage.rs` avec les types d'erreur (`StorageError`) | H | 1h | `src/config/storage.rs` | T2.1 | ⬜ | | |
| 51 | -| T2.7 | Implémenter `get_config_path()` pour déterminer le chemin du fichier de config | H | 1h | `src/config/storage.rs` | T2.6 | ⬜ | | |
| 52 | -| T2.8 | Implémenter `save_feeds(feeds: &[Feed], path: &Path)` | H | 2h | `src/config/storage.rs` | T2.6 | ⬜ | | |
| 53 | -| T2.9 | Implémenter `load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError>` | H | 2h | `src/config/storage.rs` | T2.6 | ⬜ | | |
| 54 | -| T2.10 | Ajouter la validation de l'URL dans `Feed` (méthode `validate_url()`) | M | 1h | `src/config/feed.rs` | T2.1 | ⬜ | | |
| 55 | -| T2.11 | Ajouter la validation du chemin dans `Feed` (méthode `validate_path()`) | M | 1h | `src/config/feed.rs` | T2.1 | ⬜ | | |
| 56 | -| T2.12 | Créer un test unitaire pour `save_feeds` et `load_feeds` | M | 1h | `src/config/storage.rs` | T2.8, T2.9 | ⬜ | | |
| 45 | +| T2.1 | Créer `config/feed.rs` avec la structure `Feed` | H | 1h | `src/config/feed.rs` | T1.3 | ✅ | | |
| 46 | +| T2.2 | Implémenter `Default`, `Debug`, `Clone`, `Serialize`, `Deserialize` pour `Feed` | H | 1h | `src/config/feed.rs` | T2.1 | ✅ | | |
| 47 | +| T2.3 | Créer `config/episode.rs` avec la structure `Episode` | H | 1h | `src/config/episode.rs` | T1.3 | ✅ | | |
| 48 | +| T2.4 | Créer `config/app_state.rs` avec la structure `AppState` | H | 1h | `src/config/app_state.rs` | T1.3 | ✅ | | |
| 49 | +| T2.5 | Mettre à jour `config/mod.rs` pour exporter les modules | H | 0.5h | `src/config/mod.rs` | T2.1, T2.3, T2.4 | ✅ | | |
| 50 | +| T2.6 | Créer `config/storage.rs` avec les types d'erreur (`StorageError`) | H | 1h | `src/config/storage.rs` | T2.1 | ✅ | | |
| 51 | +| T2.7 | Implémenter `get_config_path()` pour déterminer le chemin du fichier de config | H | 1h | `src/config/storage.rs` | T2.6 | ✅ | | |
| 52 | +| T2.8 | Implémenter `save_feeds(feeds: &[Feed], path: &Path)` | H | 2h | `src/config/storage.rs` | T2.6 | ✅ | | |
| 53 | +| T2.9 | Implémenter `load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError>` | H | 2h | `src/config/storage.rs` | T2.6 | ✅ | | |
| 54 | +| T2.10 | Ajouter la validation de l'URL dans `Feed` (méthode `validate_url()`) | M | 1h | `src/config/feed.rs` | T2.1 | ✅ | | |
| 55 | +| T2.11 | Ajouter la validation du chemin dans `Feed` (méthode `validate_path()`) | M | 1h | `src/config/feed.rs` | T2.1 | ✅ | | |
| 56 | +| T2.12 | Créer un test unitaire pour `save_feeds` et `load_feeds` | M | 1h | `src/config/storage.rs` | T2.8, T2.9 | ✅ | | |
| 57 | 57 | |
| 58 | 58 | **Total Phase 2** : 14 heures (2 jours) |
| 59 | 59 | |
| @@ -179,13 +179,13 @@ | ||
| 179 | 179 | | Phase | Nombre de Tâches | Durée Totale | Statut | |
| 180 | 180 | |-------|------------------|--------------|--------| |
| 181 | 181 | | Phase 1 | 8 tâches | 5h | ✅ Terminé | |
| 182 | -| Phase 2 | 12 tâches | 14h | ⬜ | | |
| 182 | +| Phase 2 | 12 tâches | 14h | ✅ Terminé | | |
| 183 | 183 | | Phase 3 | 11 tâches | 23h | ⬜ | |
| 184 | 184 | | Phase 4 | 17 tâches | 32h | ⬜ | |
| 185 | 185 | | Phase 5 | 10 tâches | 16h | ⬜ | |
| 186 | 186 | | Phase 6 | 12 tâches | 24h | ⬜ | |
| 187 | 187 | | Phase 7 | 10 tâches | 11h | ⬜ | |
| 188 | -| **Total** | **80 tâches** | **125 heures** (~16 jours) | 🟡 10% complet | | |
| 188 | +| **Total** | **80 tâches** | **125 heures** (~16 jours) | 🟡 25% complet | | |
| 189 | 189 | |
| 190 | 190 | --- |
| 191 | 191 | |
| @@ -42,18 +42,18 @@ | |||
| 42 | 42 | ||
| 43 | | ID | Tâche | Priorité | Durée | Fichiers | Dépendances | Statut | | 43 | | ID | Tâche | Priorité | Durée | Fichiers | Dépendances | Statut | |
| 44 | |----|-------|----------|-------|---------|-------------|--------| | 44 | |----|-------|----------|-------|---------|-------------|--------| |
| 45 | -| T2.1 | Créer `config/feed.rs` avec la structure `Feed` | H | 1h | `src/config/feed.rs` | T1.3 | ⬜ | | 45 | +| T2.1 | Créer `config/feed.rs` avec la structure `Feed` | H | 1h | `src/config/feed.rs` | T1.3 | ✅ | |
| 46 | -| T2.2 | Implémenter `Default`, `Debug`, `Clone`, `Serialize`, `Deserialize` pour `Feed` | H | 1h | `src/config/feed.rs` | T2.1 | ⬜ | | 46 | +| T2.2 | Implémenter `Default`, `Debug`, `Clone`, `Serialize`, `Deserialize` pour `Feed` | H | 1h | `src/config/feed.rs` | T2.1 | ✅ | |
| 47 | -| T2.3 | Créer `config/episode.rs` avec la structure `Episode` | H | 1h | `src/config/episode.rs` | T1.3 | ⬜ | | 47 | +| T2.3 | Créer `config/episode.rs` avec la structure `Episode` | H | 1h | `src/config/episode.rs` | T1.3 | ✅ | |
| 48 | -| T2.4 | Créer `config/app_state.rs` avec la structure `AppState` | H | 1h | `src/config/app_state.rs` | T1.3 | ⬜ | | 48 | +| T2.4 | Créer `config/app_state.rs` avec la structure `AppState` | H | 1h | `src/config/app_state.rs` | T1.3 | ✅ | |
| 49 | -| T2.5 | Mettre à jour `config/mod.rs` pour exporter les modules | H | 0.5h | `src/config/mod.rs` | T2.1, T2.3, T2.4 | ⬜ | | 49 | +| T2.5 | Mettre à jour `config/mod.rs` pour exporter les modules | H | 0.5h | `src/config/mod.rs` | T2.1, T2.3, T2.4 | ✅ | |
| 50 | -| T2.6 | Créer `config/storage.rs` avec les types d'erreur (`StorageError`) | H | 1h | `src/config/storage.rs` | T2.1 | ⬜ | | 50 | +| T2.6 | Créer `config/storage.rs` avec les types d'erreur (`StorageError`) | H | 1h | `src/config/storage.rs` | T2.1 | ✅ | |
| 51 | -| T2.7 | Implémenter `get_config_path()` pour déterminer le chemin du fichier de config | H | 1h | `src/config/storage.rs` | T2.6 | ⬜ | | 51 | +| T2.7 | Implémenter `get_config_path()` pour déterminer le chemin du fichier de config | H | 1h | `src/config/storage.rs` | T2.6 | ✅ | |
| 52 | -| T2.8 | Implémenter `save_feeds(feeds: &[Feed], path: &Path)` | H | 2h | `src/config/storage.rs` | T2.6 | ⬜ | | 52 | +| T2.8 | Implémenter `save_feeds(feeds: &[Feed], path: &Path)` | H | 2h | `src/config/storage.rs` | T2.6 | ✅ | |
| 53 | -| T2.9 | Implémenter `load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError>` | H | 2h | `src/config/storage.rs` | T2.6 | ⬜ | | 53 | +| T2.9 | Implémenter `load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError>` | H | 2h | `src/config/storage.rs` | T2.6 | ✅ | |
| 54 | -| T2.10 | Ajouter la validation de l'URL dans `Feed` (méthode `validate_url()`) | M | 1h | `src/config/feed.rs` | T2.1 | ⬜ | | 54 | +| T2.10 | Ajouter la validation de l'URL dans `Feed` (méthode `validate_url()`) | M | 1h | `src/config/feed.rs` | T2.1 | ✅ | |
| 55 | -| T2.11 | Ajouter la validation du chemin dans `Feed` (méthode `validate_path()`) | M | 1h | `src/config/feed.rs` | T2.1 | ⬜ | | 55 | +| T2.11 | Ajouter la validation du chemin dans `Feed` (méthode `validate_path()`) | M | 1h | `src/config/feed.rs` | T2.1 | ✅ | |
| 56 | -| T2.12 | Créer un test unitaire pour `save_feeds` et `load_feeds` | M | 1h | `src/config/storage.rs` | T2.8, T2.9 | ⬜ | | 56 | +| T2.12 | Créer un test unitaire pour `save_feeds` et `load_feeds` | M | 1h | `src/config/storage.rs` | T2.8, T2.9 | ✅ | |
| 57 | 57 | ||
| 58 | **Total Phase 2** : 14 heures (2 jours) | 58 | **Total Phase 2** : 14 heures (2 jours) |
| 59 | 59 | ||
| @@ -179,13 +179,13 @@ | |||
| 179 | | Phase | Nombre de Tâches | Durée Totale | Statut | | 179 | | Phase | Nombre de Tâches | Durée Totale | Statut | |
| 180 | |-------|------------------|--------------|--------| | 180 | |-------|------------------|--------------|--------| |
| 181 | | Phase 1 | 8 tâches | 5h | ✅ Terminé | | 181 | | Phase 1 | 8 tâches | 5h | ✅ Terminé | |
| 182 | -| Phase 2 | 12 tâches | 14h | ⬜ | | 182 | +| Phase 2 | 12 tâches | 14h | ✅ Terminé | |
| 183 | | Phase 3 | 11 tâches | 23h | ⬜ | | 183 | | Phase 3 | 11 tâches | 23h | ⬜ | |
| 184 | | Phase 4 | 17 tâches | 32h | ⬜ | | 184 | | Phase 4 | 17 tâches | 32h | ⬜ | |
| 185 | | Phase 5 | 10 tâches | 16h | ⬜ | | 185 | | Phase 5 | 10 tâches | 16h | ⬜ | |
| 186 | | Phase 6 | 12 tâches | 24h | ⬜ | | 186 | | Phase 6 | 12 tâches | 24h | ⬜ | |
| 187 | | Phase 7 | 10 tâches | 11h | ⬜ | | 187 | | Phase 7 | 10 tâches | 11h | ⬜ | |
| 188 | -| **Total** | **80 tâches** | **125 heures** (~16 jours) | 🟡 10% complet | | 188 | +| **Total** | **80 tâches** | **125 heures** (~16 jours) | 🟡 25% complet | |
| 189 | 189 | ||
| 190 | --- | 190 | --- |
| 191 | 191 | ||
modified
spec/progress.md +68 -21 | @@ -51,20 +51,67 @@ | ||
| 51 | 51 | |
| 52 | 52 | --- |
| 53 | 53 | |
| 54 | -## Phase 2 : Modèles et Stockage (0/12 tâches - 0%) | |
| 55 | - | |
| 56 | -- [ ] T2.1 - Créer `config/feed.rs` avec la structure `Feed` | |
| 57 | -- [ ] T2.2 - Implémenter `Default`, `Debug`, `Clone`, `Serialize`, `Deserialize` pour `Feed` | |
| 58 | -- [ ] T2.3 - Créer `config/episode.rs` avec la structure `Episode` | |
| 59 | -- [ ] T2.4 - Créer `config/app_state.rs` avec la structure `AppState` | |
| 60 | -- [ ] T2.5 - Mettre à jour `config/mod.rs` pour exporter les modules | |
| 61 | -- [ ] T2.6 - Créer `config/storage.rs` avec les types d'erreur (`StorageError`) | |
| 62 | -- [ ] T2.7 - Implémenter `get_config_path()` pour déterminer le chemin du fichier de config | |
| 63 | -- [ ] T2.8 - Implémenter `save_feeds(feeds: &[Feed], path: &Path)` | |
| 64 | -- [ ] T2.9 - Implémenter `load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError>` | |
| 65 | -- [ ] T2.10 - Ajouter la validation de l'URL dans `Feed` (méthode `validate_url()`) | |
| 66 | -- [ ] T2.11 - Ajouter la validation du chemin dans `Feed` (méthode `validate_path()`) | |
| 67 | -- [ ] T2.12 - Créer un test unitaire pour `save_feeds` et `load_feeds` | |
| 54 | +## Phase 2 : Modèles et Stockage (12/12 tâches - 100%) | |
| 55 | + | |
| 56 | +- [x] T2.1 - Créer `config/feed.rs` avec la structure `Feed` | |
| 57 | + - *Fichiers: src/config/feed.rs* | |
| 58 | + - *Contenu: Structure Feed avec tous les champs, impl Default, méthodes de création* | |
| 59 | + - *Date: 03/07/2026* | |
| 60 | + | |
| 61 | +- [x] T2.2 - Implémenter `Default`, `Debug`, `Clone`, `Serialize`, `Deserialize` pour `Feed` | |
| 62 | + - *Fichiers: src/config/feed.rs* | |
| 63 | + - *Contenu: Tous les traits implémentés, PartialEq ajouté* | |
| 64 | + - *Date: 03/07/2026* | |
| 65 | + | |
| 66 | +- [x] T2.3 - Créer `config/episode.rs` avec la structure `Episode` | |
| 67 | + - *Fichiers: src/config/episode.rs* | |
| 68 | + - *Contenu: Structure Episode avec tous les champs, impl Default, méthodes utilitaires* | |
| 69 | + - *Date: 03/07/2026* | |
| 70 | + | |
| 71 | +- [x] T2.4 - Créer `config/app_state.rs` avec la structure `AppState` | |
| 72 | + - *Fichiers: src/config/app_state.rs* | |
| 73 | + - *Contenu: Structure AppState avec gestion des feeds, messages, progression* | |
| 74 | + - *Date: 03/07/2026* | |
| 75 | + | |
| 76 | +- [x] T2.5 - Mettre à jour `config/mod.rs` pour exporter les modules | |
| 77 | + - *Fichiers: src/config/mod.rs* | |
| 78 | + - *Contenu: Re-export des types Feed, Episode, AppState, StorageError et fonctions* | |
| 79 | + - *Date: 03/07/2026* | |
| 80 | + | |
| 81 | +- [x] T2.6 - Créer `config/storage.rs` avec les types d'erreur (`StorageError`) | |
| 82 | + - *Fichiers: src/config/storage.rs* | |
| 83 | + - *Contenu: Enum StorageError avec tous les variantes (ReadError, WriteError, etc.)* | |
| 84 | + - *Date: 03/07/2026* | |
| 85 | + | |
| 86 | +- [x] T2.7 - Implémenter `get_config_path()` pour déterminer le chemin du fichier de config | |
| 87 | + - *Fichiers: src/config/storage.rs* | |
| 88 | + - *Contenu: Support Windows (%APPDATA%) et Unix (~/.config/)* | |
| 89 | + - *Date: 03/07/2026* | |
| 90 | + | |
| 91 | +- [x] T2.8 - Implémenter `save_feeds(feeds: &[Feed], path: &Path)` | |
| 92 | + - *Fichiers: src/config/storage.rs* | |
| 93 | + - *Contenu: Création des dossiers parents, sérialisation JSON, écriture fichier* | |
| 94 | + - *Date: 03/07/2026* | |
| 95 | + | |
| 96 | +- [x] T2.9 - Implémenter `load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError>` | |
| 97 | + - *Fichiers: src/config/storage.rs* | |
| 98 | + - *Contenu: Lecture fichier, parsing JSON, retour Vec vide si fichier inexistant* | |
| 99 | + - *Date: 03/07/2026* | |
| 100 | + | |
| 101 | +- [x] T2.10 - Ajouter la validation de l'URL dans `Feed` (méthode `validate_url()`) | |
| 102 | + - *Fichiers: src/config/feed.rs* | |
| 103 | + - *Contenu: Vérification URL non vide et protocole http/https* | |
| 104 | + - *Date: 03/07/2026* | |
| 105 | + | |
| 106 | +- [x] T2.11 - Ajouter la validation du chemin dans `Feed` (méthode `validate_path()`) | |
| 107 | + - *Fichiers: src/config/feed.rs* | |
| 108 | + - *Contenu: Vérification chemin non vide* | |
| 109 | + - *Date: 03/07/2026* | |
| 110 | + | |
| 111 | +- [x] T2.12 - Créer un test unitaire pour `save_feeds` et `load_feeds` | |
| 112 | + - *Fichiers: src/config/feed.rs, src/config/episode.rs, src/config/app_state.rs, src/config/storage.rs* | |
| 113 | + - *Contenu: Tests complets pour toutes les fonctions principales* | |
| 114 | + - *Date: 03/07/2026* | |
| 68 | 115 | |
| 69 | 116 | --- |
| 70 | 117 | |
| @@ -158,23 +205,23 @@ | ||
| 158 | 205 | | Phase | Total | Terminées | % Complet | Statut | |
| 159 | 206 | |-------|-------|-----------|------------|--------| |
| 160 | 207 | | Phase 1 | 8 | 8 | 100% | ✅ Terminé | |
| 161 | -| Phase 2 | 12 | 0 | 0% | ⏳ À faire | | |
| 208 | +| Phase 2 | 12 | 12 | 100% | ✅ Terminé | | |
| 162 | 209 | | Phase 3 | 11 | 0 | 0% | ⏳ À faire | |
| 163 | 210 | | Phase 4 | 17 | 0 | 0% | ⏳ À faire | |
| 164 | 211 | | Phase 5 | 10 | 0 | 0% | ⏳ À faire | |
| 165 | 212 | | Phase 6 | 12 | 0 | 0% | ⏳ À faire | |
| 166 | 213 | | Phase 7 | 10 | 0 | 0% | ⏳ À faire | |
| 167 | -| **Total** | **80** | **8** | **10%** | 🟡 En cours | | |
| 214 | +| **Total** | **80** | **20** | **25%** | 🟡 En cours | | |
| 168 | 215 | |
| 169 | 216 | --- |
| 170 | 217 | |
| 171 | 218 | ## Prochaines Étapes |
| 172 | 219 | |
| 173 | -### Priorité Haute (Phase 2) | |
| 174 | -1. **T2.1** - Créer `config/feed.rs` avec la structure `Feed` | |
| 175 | -2. **T2.2** - Implémenter les traits pour `Feed` | |
| 176 | -3. **T2.3** - Créer `config/episode.rs` | |
| 177 | -4. **T2.4** - Créer `config/app_state.rs` | |
| 220 | +### Priorité Haute (Phase 3) | |
| 221 | +1. **T3.1** - Créer `rss/parser.rs` avec les types d'erreur (`ParserError`) | |
| 222 | +2. **T3.2** - Implémenter `fetch_feed(url: &str) -> Result<String, ParserError>` | |
| 223 | +3. **T3.3** - Implémenter `parse_rss_content(content: &str) -> Result<RssFeed, ParserError>` | |
| 224 | +4. **T3.4** - Créer `rss/episode_extractor.rs` avec `extract_episodes(feed: &RssFeed) -> Vec<Episode>` | |
| 178 | 225 | |
| 179 | 226 | ### Commandes pour vérifier |
| 180 | 227 | ```bash |
| @@ -51,20 +51,67 @@ | |||
| 51 | 51 | ||
| 52 | --- | 52 | --- |
| 53 | 53 | ||
| 54 | -## Phase 2 : Modèles et Stockage (0/12 tâches - 0%) | 54 | +## Phase 2 : Modèles et Stockage (12/12 tâches - 100%) |
| 55 | - | 55 | + |
| 56 | -- [ ] T2.1 - Créer `config/feed.rs` avec la structure `Feed` | 56 | +- [x] T2.1 - Créer `config/feed.rs` avec la structure `Feed` |
| 57 | -- [ ] T2.2 - Implémenter `Default`, `Debug`, `Clone`, `Serialize`, `Deserialize` pour `Feed` | 57 | + - *Fichiers: src/config/feed.rs* |
| 58 | -- [ ] T2.3 - Créer `config/episode.rs` avec la structure `Episode` | 58 | + - *Contenu: Structure Feed avec tous les champs, impl Default, méthodes de création* |
| 59 | -- [ ] T2.4 - Créer `config/app_state.rs` avec la structure `AppState` | 59 | + - *Date: 03/07/2026* |
| 60 | -- [ ] T2.5 - Mettre à jour `config/mod.rs` pour exporter les modules | 60 | + |
| 61 | -- [ ] T2.6 - Créer `config/storage.rs` avec les types d'erreur (`StorageError`) | 61 | +- [x] T2.2 - Implémenter `Default`, `Debug`, `Clone`, `Serialize`, `Deserialize` pour `Feed` |
| 62 | -- [ ] T2.7 - Implémenter `get_config_path()` pour déterminer le chemin du fichier de config | 62 | + - *Fichiers: src/config/feed.rs* |
| 63 | -- [ ] T2.8 - Implémenter `save_feeds(feeds: &[Feed], path: &Path)` | 63 | + - *Contenu: Tous les traits implémentés, PartialEq ajouté* |
| 64 | -- [ ] T2.9 - Implémenter `load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError>` | 64 | + - *Date: 03/07/2026* |
| 65 | -- [ ] T2.10 - Ajouter la validation de l'URL dans `Feed` (méthode `validate_url()`) | 65 | + |
| 66 | -- [ ] T2.11 - Ajouter la validation du chemin dans `Feed` (méthode `validate_path()`) | 66 | +- [x] T2.3 - Créer `config/episode.rs` avec la structure `Episode` |
| 67 | -- [ ] T2.12 - Créer un test unitaire pour `save_feeds` et `load_feeds` | 67 | + - *Fichiers: src/config/episode.rs* |
| 68 | + - *Contenu: Structure Episode avec tous les champs, impl Default, méthodes utilitaires* | ||
| 69 | + - *Date: 03/07/2026* | ||
| 70 | + | ||
| 71 | +- [x] T2.4 - Créer `config/app_state.rs` avec la structure `AppState` | ||
| 72 | + - *Fichiers: src/config/app_state.rs* | ||
| 73 | + - *Contenu: Structure AppState avec gestion des feeds, messages, progression* | ||
| 74 | + - *Date: 03/07/2026* | ||
| 75 | + | ||
| 76 | +- [x] T2.5 - Mettre à jour `config/mod.rs` pour exporter les modules | ||
| 77 | + - *Fichiers: src/config/mod.rs* | ||
| 78 | + - *Contenu: Re-export des types Feed, Episode, AppState, StorageError et fonctions* | ||
| 79 | + - *Date: 03/07/2026* | ||
| 80 | + | ||
| 81 | +- [x] T2.6 - Créer `config/storage.rs` avec les types d'erreur (`StorageError`) | ||
| 82 | + - *Fichiers: src/config/storage.rs* | ||
| 83 | + - *Contenu: Enum StorageError avec tous les variantes (ReadError, WriteError, etc.)* | ||
| 84 | + - *Date: 03/07/2026* | ||
| 85 | + | ||
| 86 | +- [x] T2.7 - Implémenter `get_config_path()` pour déterminer le chemin du fichier de config | ||
| 87 | + - *Fichiers: src/config/storage.rs* | ||
| 88 | + - *Contenu: Support Windows (%APPDATA%) et Unix (~/.config/)* | ||
| 89 | + - *Date: 03/07/2026* | ||
| 90 | + | ||
| 91 | +- [x] T2.8 - Implémenter `save_feeds(feeds: &[Feed], path: &Path)` | ||
| 92 | + - *Fichiers: src/config/storage.rs* | ||
| 93 | + - *Contenu: Création des dossiers parents, sérialisation JSON, écriture fichier* | ||
| 94 | + - *Date: 03/07/2026* | ||
| 95 | + | ||
| 96 | +- [x] T2.9 - Implémenter `load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError>` | ||
| 97 | + - *Fichiers: src/config/storage.rs* | ||
| 98 | + - *Contenu: Lecture fichier, parsing JSON, retour Vec vide si fichier inexistant* | ||
| 99 | + - *Date: 03/07/2026* | ||
| 100 | + | ||
| 101 | +- [x] T2.10 - Ajouter la validation de l'URL dans `Feed` (méthode `validate_url()`) | ||
| 102 | + - *Fichiers: src/config/feed.rs* | ||
| 103 | + - *Contenu: Vérification URL non vide et protocole http/https* | ||
| 104 | + - *Date: 03/07/2026* | ||
| 105 | + | ||
| 106 | +- [x] T2.11 - Ajouter la validation du chemin dans `Feed` (méthode `validate_path()`) | ||
| 107 | + - *Fichiers: src/config/feed.rs* | ||
| 108 | + - *Contenu: Vérification chemin non vide* | ||
| 109 | + - *Date: 03/07/2026* | ||
| 110 | + | ||
| 111 | +- [x] T2.12 - Créer un test unitaire pour `save_feeds` et `load_feeds` | ||
| 112 | + - *Fichiers: src/config/feed.rs, src/config/episode.rs, src/config/app_state.rs, src/config/storage.rs* | ||
| 113 | + - *Contenu: Tests complets pour toutes les fonctions principales* | ||
| 114 | + - *Date: 03/07/2026* | ||
| 68 | 115 | ||
| 69 | --- | 116 | --- |
| 70 | 117 | ||
| @@ -158,23 +205,23 @@ | |||
| 158 | | Phase | Total | Terminées | % Complet | Statut | | 205 | | Phase | Total | Terminées | % Complet | Statut | |
| 159 | |-------|-------|-----------|------------|--------| | 206 | |-------|-------|-----------|------------|--------| |
| 160 | | Phase 1 | 8 | 8 | 100% | ✅ Terminé | | 207 | | Phase 1 | 8 | 8 | 100% | ✅ Terminé | |
| 161 | -| Phase 2 | 12 | 0 | 0% | ⏳ À faire | | 208 | +| Phase 2 | 12 | 12 | 100% | ✅ Terminé | |
| 162 | | Phase 3 | 11 | 0 | 0% | ⏳ À faire | | 209 | | Phase 3 | 11 | 0 | 0% | ⏳ À faire | |
| 163 | | Phase 4 | 17 | 0 | 0% | ⏳ À faire | | 210 | | Phase 4 | 17 | 0 | 0% | ⏳ À faire | |
| 164 | | Phase 5 | 10 | 0 | 0% | ⏳ À faire | | 211 | | Phase 5 | 10 | 0 | 0% | ⏳ À faire | |
| 165 | | Phase 6 | 12 | 0 | 0% | ⏳ À faire | | 212 | | Phase 6 | 12 | 0 | 0% | ⏳ À faire | |
| 166 | | Phase 7 | 10 | 0 | 0% | ⏳ À faire | | 213 | | Phase 7 | 10 | 0 | 0% | ⏳ À faire | |
| 167 | -| **Total** | **80** | **8** | **10%** | 🟡 En cours | | 214 | +| **Total** | **80** | **20** | **25%** | 🟡 En cours | |
| 168 | 215 | ||
| 169 | --- | 216 | --- |
| 170 | 217 | ||
| 171 | ## Prochaines Étapes | 218 | ## Prochaines Étapes |
| 172 | 219 | ||
| 173 | -### Priorité Haute (Phase 2) | 220 | +### Priorité Haute (Phase 3) |
| 174 | -1. **T2.1** - Créer `config/feed.rs` avec la structure `Feed` | 221 | +1. **T3.1** - Créer `rss/parser.rs` avec les types d'erreur (`ParserError`) |
| 175 | -2. **T2.2** - Implémenter les traits pour `Feed` | 222 | +2. **T3.2** - Implémenter `fetch_feed(url: &str) -> Result<String, ParserError>` |
| 176 | -3. **T2.3** - Créer `config/episode.rs` | 223 | +3. **T3.3** - Implémenter `parse_rss_content(content: &str) -> Result<RssFeed, ParserError>` |
| 177 | -4. **T2.4** - Créer `config/app_state.rs` | 224 | +4. **T3.4** - Créer `rss/episode_extractor.rs` avec `extract_episodes(feed: &RssFeed) -> Vec<Episode>` |
| 178 | 225 | ||
| 179 | ### Commandes pour vérifier | 226 | ### Commandes pour vérifier |
| 180 | ```bash | 227 | ```bash |
added
src/config/app_state.rs +334 -0 | new file mode 100644 | ||
| @@ -0,0 +1,334 @@ | ||
| 1 | +//! AppState module - Application state management | |
| 2 | +//! | |
| 3 | +//! This module defines the main application state structure. | |
| 4 | + | |
| 5 | +use crate::config::{episode::Episode, feed::Feed}; | |
| 6 | +use std::collections::HashMap; | |
| 7 | +use std::path::PathBuf; | |
| 8 | + | |
| 9 | +/// Main application state | |
| 10 | +/// | |
| 11 | +/// This struct holds all the state needed for the RSS Podcast Downloader application. | |
| 12 | +#[derive(Debug, Default)] | |
| 13 | +pub struct AppState { | |
| 14 | + /// List of configured RSS feeds | |
| 15 | + pub feeds: Vec<Feed>, | |
| 16 | + /// Index of the currently selected feed (None if no selection) | |
| 17 | + pub current_feed: Option<usize>, | |
| 18 | + /// Whether the add feed dialog is visible | |
| 19 | + pub show_add_feed_dialog: bool, | |
| 20 | + /// Whether the edit feed dialog is visible | |
| 21 | + pub show_edit_feed_dialog: bool, | |
| 22 | + /// Index of the feed being edited (if any) | |
| 23 | + pub editing_feed_index: Option<usize>, | |
| 24 | + /// Whether a synchronization is currently in progress | |
| 25 | + pub sync_in_progress: bool, | |
| 26 | + /// Download progress for each feed (feed_id -> progress 0.0-1.0) | |
| 27 | + pub download_progress: HashMap<String, f32>, | |
| 28 | + /// Current error message to display (if any) | |
| 29 | + pub error_message: Option<String>, | |
| 30 | + /// Current info message to display (if any) | |
| 31 | + pub info_message: Option<String>, | |
| 32 | + /// Whether the settings dialog is visible | |
| 33 | + pub show_settings: bool, | |
| 34 | + /// Path to the configuration file | |
| 35 | + pub config_path: PathBuf, | |
| 36 | +} | |
| 37 | + | |
| 38 | +impl AppState { | |
| 39 | + /// Creates a new AppState with the given configuration path | |
| 40 | + pub fn new(config_path: impl Into<PathBuf>) -> Self { | |
| 41 | + Self { | |
| 42 | + feeds: Vec::new(), | |
| 43 | + current_feed: None, | |
| 44 | + show_add_feed_dialog: false, | |
| 45 | + show_edit_feed_dialog: false, | |
| 46 | + editing_feed_index: None, | |
| 47 | + sync_in_progress: false, | |
| 48 | + download_progress: HashMap::new(), | |
| 49 | + error_message: None, | |
| 50 | + info_message: None, | |
| 51 | + show_settings: false, | |
| 52 | + config_path: config_path.into(), | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + /// Creates a new AppState with default configuration path | |
| 57 | + pub fn with_default_config() -> Self { | |
| 58 | + Self::new(Self::default_config_path()) | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Returns the default configuration path | |
| 62 | + pub fn default_config_path() -> PathBuf { | |
| 63 | + // On Windows: %APPDATA%/rss-podcast-dl/config.json | |
| 64 | + // On Unix: ~/.config/rss-podcast-dl/config.json | |
| 65 | + if cfg!(windows) { | |
| 66 | + if let Some(app_data) = std::env::var_os("APPDATA") { | |
| 67 | + PathBuf::from(app_data).join("rss-podcast-dl").join("config.json") | |
| 68 | + } else { | |
| 69 | + PathBuf::from("config.json") | |
| 70 | + } | |
| 71 | + } else { | |
| 72 | + if let Some(home) = std::env::var_os("HOME") { | |
| 73 | + PathBuf::from(home).join(".config").join("rss-podcast-dl").join("config.json") | |
| 74 | + } else { | |
| 75 | + PathBuf::from("config.json") | |
| 76 | + } | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + /// Adds a new feed to the application state | |
| 81 | + pub fn add_feed(&mut self, feed: Feed) { | |
| 82 | + self.feeds.push(feed); | |
| 83 | + self.info_message = Some("Flux ajouté avec succès".to_string()); | |
| 84 | + } | |
| 85 | + | |
| 86 | + /// Removes a feed by index | |
| 87 | + pub fn remove_feed(&mut self, index: usize) -> Option<Feed> { | |
| 88 | + if index < self.feeds.len() { | |
| 89 | + let feed = self.feeds.remove(index); | |
| 90 | + | |
| 91 | + // Clear current selection if it was the removed feed | |
| 92 | + if let Some(current) = self.current_feed { | |
| 93 | + if current >= index { | |
| 94 | + self.current_feed = if current > 0 { Some(current - 1) } else { None }; | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + self.info_message = Some("Flux supprimé avec succès".to_string()); | |
| 99 | + Some(feed) | |
| 100 | + } else { | |
| 101 | + None | |
| 102 | + } | |
| 103 | + } | |
| 104 | + | |
| 105 | + /// Gets the currently selected feed | |
| 106 | + pub fn get_current_feed(&self) -> Option<&Feed> { | |
| 107 | + self.current_feed.and_then(|idx| self.feeds.get(idx)) | |
| 108 | + } | |
| 109 | + | |
| 110 | + /// Gets the currently selected feed as mutable | |
| 111 | + pub fn get_current_feed_mut(&mut self) -> Option<&mut Feed> { | |
| 112 | + self.current_feed.and_then(|idx| self.feeds.get_mut(idx)) | |
| 113 | + } | |
| 114 | + | |
| 115 | + /// Selects a feed by index | |
| 116 | + pub fn select_feed(&mut self, index: Option<usize>) { | |
| 117 | + self.current_feed = index; | |
| 118 | + } | |
| 119 | + | |
| 120 | + /// Toggles the enabled state of a feed | |
| 121 | + pub fn toggle_feed_enabled(&mut self, index: usize) { | |
| 122 | + if let Some(feed) = self.feeds.get_mut(index) { | |
| 123 | + feed.enabled = !feed.enabled; | |
| 124 | + let status = if feed.enabled { "activé" } else { "désactivé" }; | |
| 125 | + self.info_message = Some(format!("Flux {}", status)); | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + /// Sets an error message | |
| 130 | + pub fn set_error(&mut self, message: impl Into<String>) { | |
| 131 | + self.error_message = Some(message.into()); | |
| 132 | + } | |
| 133 | + | |
| 134 | + /// Sets an info message | |
| 135 | + pub fn set_info(&mut self, message: impl Into<String>) { | |
| 136 | + self.info_message = Some(message.into()); | |
| 137 | + } | |
| 138 | + | |
| 139 | + /// Clears all messages | |
| 140 | + pub fn clear_messages(&mut self) { | |
| 141 | + self.error_message = None; | |
| 142 | + self.info_message = None; | |
| 143 | + } | |
| 144 | + | |
| 145 | + /// Clears the error message | |
| 146 | + pub fn clear_error(&mut self) { | |
| 147 | + self.error_message = None; | |
| 148 | + } | |
| 149 | + | |
| 150 | + /// Clears the info message | |
| 151 | + pub fn clear_info(&mut self) { | |
| 152 | + self.info_message = None; | |
| 153 | + } | |
| 154 | + | |
| 155 | + /// Returns the number of feeds | |
| 156 | + pub fn feed_count(&self) -> usize { | |
| 157 | + self.feeds.len() | |
| 158 | + } | |
| 159 | + | |
| 160 | + /// Returns the number of enabled feeds | |
| 161 | + pub fn enabled_feed_count(&self) -> usize { | |
| 162 | + self.feeds.iter().filter(|f| f.enabled).count() | |
| 163 | + } | |
| 164 | + | |
| 165 | + /// Returns true if there are any feeds configured | |
| 166 | + pub fn has_feeds(&self) -> bool { | |
| 167 | + !self.feeds.is_empty() | |
| 168 | + } | |
| 169 | + | |
| 170 | + /// Returns true if synchronization is in progress | |
| 171 | + pub fn is_syncing(&self) -> bool { | |
| 172 | + self.sync_in_progress | |
| 173 | + } | |
| 174 | + | |
| 175 | + /// Sets the synchronization state | |
| 176 | + pub fn set_sync_state(&mut self, syncing: bool) { | |
| 177 | + self.sync_in_progress = syncing; | |
| 178 | + } | |
| 179 | + | |
| 180 | + /// Updates the download progress for a feed | |
| 181 | + pub fn update_download_progress(&mut self, feed_id: &str, progress: f32) { | |
| 182 | + self.download_progress.insert(feed_id.to_string(), progress.clamp(0.0, 1.0)); | |
| 183 | + } | |
| 184 | + | |
| 185 | + /// Removes the download progress for a feed | |
| 186 | + pub fn remove_download_progress(&mut self, feed_id: &str) { | |
| 187 | + self.download_progress.remove(feed_id); | |
| 188 | + } | |
| 189 | + | |
| 190 | + /// Gets the download progress for a feed | |
| 191 | + pub fn get_download_progress(&self, feed_id: &str) -> f32 { | |
| 192 | + *self.download_progress.get(feed_id).unwrap_or(&0.0) | |
| 193 | + } | |
| 194 | +} | |
| 195 | + | |
| 196 | +#[cfg(test)] | |
| 197 | +mod tests { | |
| 198 | + use super::*; | |
| 199 | + use crate::config::feed::Feed; | |
| 200 | + | |
| 201 | + #[test] | |
| 202 | + fn test_app_state_new() { | |
| 203 | + let app_state = AppState::new("test/config.json"); | |
| 204 | + assert!(app_state.feeds.is_empty()); | |
| 205 | + assert!(app_state.current_feed.is_none()); | |
| 206 | + assert!(!app_state.sync_in_progress); | |
| 207 | + } | |
| 208 | + | |
| 209 | + #[test] | |
| 210 | + fn test_app_state_default_config_path_windows() { | |
| 211 | + // This test just verifies the function doesn't panic | |
| 212 | + let path = AppState::default_config_path(); | |
| 213 | + assert!(!path.to_string_lossy().is_empty()); | |
| 214 | + } | |
| 215 | + | |
| 216 | + #[test] | |
| 217 | + fn test_add_feed() { | |
| 218 | + let mut app_state = AppState::new("test/config.json"); | |
| 219 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | |
| 220 | + | |
| 221 | + app_state.add_feed(feed); | |
| 222 | + | |
| 223 | + assert_eq!(app_state.feeds.len(), 1); | |
| 224 | + assert_eq!(app_state.feeds[0].name, "Test Podcast"); | |
| 225 | + } | |
| 226 | + | |
| 227 | + #[test] | |
| 228 | + fn test_remove_feed() { | |
| 229 | + let mut app_state = AppState::new("test/config.json"); | |
| 230 | + let feed1 = Feed::new("https://example.com/rss1", "Podcast 1", "/downloads1"); | |
| 231 | + let feed2 = Feed::new("https://example.com/rss2", "Podcast 2", "/downloads2"); | |
| 232 | + | |
| 233 | + app_state.add_feed(feed1); | |
| 234 | + app_state.add_feed(feed2); | |
| 235 | + | |
| 236 | + let removed = app_state.remove_feed(0); | |
| 237 | + assert!(removed.is_some()); | |
| 238 | + assert_eq!(removed.unwrap().name, "Podcast 1"); | |
| 239 | + assert_eq!(app_state.feeds.len(), 1); | |
| 240 | + assert_eq!(app_state.feeds[0].name, "Podcast 2"); | |
| 241 | + } | |
| 242 | + | |
| 243 | + #[test] | |
| 244 | + fn test_select_feed() { | |
| 245 | + let mut app_state = AppState::new("test/config.json"); | |
| 246 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | |
| 247 | + | |
| 248 | + app_state.add_feed(feed); | |
| 249 | + app_state.select_feed(Some(0)); | |
| 250 | + | |
| 251 | + assert_eq!(app_state.current_feed, Some(0)); | |
| 252 | + assert_eq!(app_state.get_current_feed().unwrap().name, "Test Podcast"); | |
| 253 | + } | |
| 254 | + | |
| 255 | + #[test] | |
| 256 | + fn test_toggle_feed_enabled() { | |
| 257 | + let mut app_state = AppState::new("test/config.json"); | |
| 258 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | |
| 259 | + | |
| 260 | + app_state.add_feed(feed); | |
| 261 | + | |
| 262 | + // Initially enabled | |
| 263 | + assert!(app_state.feeds[0].enabled); | |
| 264 | + | |
| 265 | + // Toggle to disabled | |
| 266 | + app_state.toggle_feed_enabled(0); | |
| 267 | + assert!(!app_state.feeds[0].enabled); | |
| 268 | + | |
| 269 | + // Toggle back to enabled | |
| 270 | + app_state.toggle_feed_enabled(0); | |
| 271 | + assert!(app_state.feeds[0].enabled); | |
| 272 | + } | |
| 273 | + | |
| 274 | + #[test] | |
| 275 | + fn test_feed_count() { | |
| 276 | + let mut app_state = AppState::new("test/config.json"); | |
| 277 | + assert_eq!(app_state.feed_count(), 0); | |
| 278 | + | |
| 279 | + app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); | |
| 280 | + assert_eq!(app_state.feed_count(), 1); | |
| 281 | + | |
| 282 | + app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); | |
| 283 | + assert_eq!(app_state.feed_count(), 2); | |
| 284 | + } | |
| 285 | + | |
| 286 | + #[test] | |
| 287 | + fn test_enabled_feed_count() { | |
| 288 | + let mut app_state = AppState::new("test/config.json"); | |
| 289 | + | |
| 290 | + app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled | |
| 291 | + app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled | |
| 292 | + app_state.add_feed(Feed::new("url3", "Podcast 3", "path3")); // will be disabled | |
| 293 | + | |
| 294 | + app_state.feeds[2].enabled = false; | |
| 295 | + | |
| 296 | + assert_eq!(app_state.enabled_feed_count(), 2); | |
| 297 | + } | |
| 298 | + | |
| 299 | + #[test] | |
| 300 | + fn test_messages() { | |
| 301 | + let mut app_state = AppState::new("test/config.json"); | |
| 302 | + | |
| 303 | + assert!(app_state.error_message.is_none()); | |
| 304 | + assert!(app_state.info_message.is_none()); | |
| 305 | + | |
| 306 | + app_state.set_error("Test error"); | |
| 307 | + assert_eq!(app_state.error_message, Some("Test error".to_string())); | |
| 308 | + | |
| 309 | + app_state.set_info("Test info"); | |
| 310 | + assert_eq!(app_state.info_message, Some("Test info".to_string())); | |
| 311 | + | |
| 312 | + app_state.clear_messages(); | |
| 313 | + assert!(app_state.error_message.is_none()); | |
| 314 | + assert!(app_state.info_message.is_none()); | |
| 315 | + } | |
| 316 | + | |
| 317 | + #[test] | |
| 318 | + fn test_download_progress() { | |
| 319 | + let mut app_state = AppState::new("test/config.json"); | |
| 320 | + | |
| 321 | + app_state.update_download_progress("feed-1", 0.5); | |
| 322 | + assert_eq!(app_state.get_download_progress("feed-1"), 0.5); | |
| 323 | + | |
| 324 | + // Test clamping | |
| 325 | + app_state.update_download_progress("feed-2", 1.5); | |
| 326 | + assert_eq!(app_state.get_download_progress("feed-2"), 1.0); | |
| 327 | + | |
| 328 | + app_state.update_download_progress("feed-3", -0.5); | |
| 329 | + assert_eq!(app_state.get_download_progress("feed-3"), 0.0); | |
| 330 | + | |
| 331 | + // Non-existent feed | |
| 332 | + assert_eq!(app_state.get_download_progress("feed-4"), 0.0); | |
| 333 | + } | |
| 334 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,334 @@ | |||
| 1 | +//! AppState module - Application state management | ||
| 2 | +//! | ||
| 3 | +//! This module defines the main application state structure. | ||
| 4 | + | ||
| 5 | +use crate::config::{episode::Episode, feed::Feed}; | ||
| 6 | +use std::collections::HashMap; | ||
| 7 | +use std::path::PathBuf; | ||
| 8 | + | ||
| 9 | +/// Main application state | ||
| 10 | +/// | ||
| 11 | +/// This struct holds all the state needed for the RSS Podcast Downloader application. | ||
| 12 | +#[derive(Debug, Default)] | ||
| 13 | +pub struct AppState { | ||
| 14 | + /// List of configured RSS feeds | ||
| 15 | + pub feeds: Vec<Feed>, | ||
| 16 | + /// Index of the currently selected feed (None if no selection) | ||
| 17 | + pub current_feed: Option<usize>, | ||
| 18 | + /// Whether the add feed dialog is visible | ||
| 19 | + pub show_add_feed_dialog: bool, | ||
| 20 | + /// Whether the edit feed dialog is visible | ||
| 21 | + pub show_edit_feed_dialog: bool, | ||
| 22 | + /// Index of the feed being edited (if any) | ||
| 23 | + pub editing_feed_index: Option<usize>, | ||
| 24 | + /// Whether a synchronization is currently in progress | ||
| 25 | + pub sync_in_progress: bool, | ||
| 26 | + /// Download progress for each feed (feed_id -> progress 0.0-1.0) | ||
| 27 | + pub download_progress: HashMap<String, f32>, | ||
| 28 | + /// Current error message to display (if any) | ||
| 29 | + pub error_message: Option<String>, | ||
| 30 | + /// Current info message to display (if any) | ||
| 31 | + pub info_message: Option<String>, | ||
| 32 | + /// Whether the settings dialog is visible | ||
| 33 | + pub show_settings: bool, | ||
| 34 | + /// Path to the configuration file | ||
| 35 | + pub config_path: PathBuf, | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +impl AppState { | ||
| 39 | + /// Creates a new AppState with the given configuration path | ||
| 40 | + pub fn new(config_path: impl Into<PathBuf>) -> Self { | ||
| 41 | + Self { | ||
| 42 | + feeds: Vec::new(), | ||
| 43 | + current_feed: None, | ||
| 44 | + show_add_feed_dialog: false, | ||
| 45 | + show_edit_feed_dialog: false, | ||
| 46 | + editing_feed_index: None, | ||
| 47 | + sync_in_progress: false, | ||
| 48 | + download_progress: HashMap::new(), | ||
| 49 | + error_message: None, | ||
| 50 | + info_message: None, | ||
| 51 | + show_settings: false, | ||
| 52 | + config_path: config_path.into(), | ||
| 53 | + } | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + /// Creates a new AppState with default configuration path | ||
| 57 | + pub fn with_default_config() -> Self { | ||
| 58 | + Self::new(Self::default_config_path()) | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + /// Returns the default configuration path | ||
| 62 | + pub fn default_config_path() -> PathBuf { | ||
| 63 | + // On Windows: %APPDATA%/rss-podcast-dl/config.json | ||
| 64 | + // On Unix: ~/.config/rss-podcast-dl/config.json | ||
| 65 | + if cfg!(windows) { | ||
| 66 | + if let Some(app_data) = std::env::var_os("APPDATA") { | ||
| 67 | + PathBuf::from(app_data).join("rss-podcast-dl").join("config.json") | ||
| 68 | + } else { | ||
| 69 | + PathBuf::from("config.json") | ||
| 70 | + } | ||
| 71 | + } else { | ||
| 72 | + if let Some(home) = std::env::var_os("HOME") { | ||
| 73 | + PathBuf::from(home).join(".config").join("rss-podcast-dl").join("config.json") | ||
| 74 | + } else { | ||
| 75 | + PathBuf::from("config.json") | ||
| 76 | + } | ||
| 77 | + } | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + /// Adds a new feed to the application state | ||
| 81 | + pub fn add_feed(&mut self, feed: Feed) { | ||
| 82 | + self.feeds.push(feed); | ||
| 83 | + self.info_message = Some("Flux ajouté avec succès".to_string()); | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + /// Removes a feed by index | ||
| 87 | + pub fn remove_feed(&mut self, index: usize) -> Option<Feed> { | ||
| 88 | + if index < self.feeds.len() { | ||
| 89 | + let feed = self.feeds.remove(index); | ||
| 90 | + | ||
| 91 | + // Clear current selection if it was the removed feed | ||
| 92 | + if let Some(current) = self.current_feed { | ||
| 93 | + if current >= index { | ||
| 94 | + self.current_feed = if current > 0 { Some(current - 1) } else { None }; | ||
| 95 | + } | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + self.info_message = Some("Flux supprimé avec succès".to_string()); | ||
| 99 | + Some(feed) | ||
| 100 | + } else { | ||
| 101 | + None | ||
| 102 | + } | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + /// Gets the currently selected feed | ||
| 106 | + pub fn get_current_feed(&self) -> Option<&Feed> { | ||
| 107 | + self.current_feed.and_then(|idx| self.feeds.get(idx)) | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + /// Gets the currently selected feed as mutable | ||
| 111 | + pub fn get_current_feed_mut(&mut self) -> Option<&mut Feed> { | ||
| 112 | + self.current_feed.and_then(|idx| self.feeds.get_mut(idx)) | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + /// Selects a feed by index | ||
| 116 | + pub fn select_feed(&mut self, index: Option<usize>) { | ||
| 117 | + self.current_feed = index; | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + /// Toggles the enabled state of a feed | ||
| 121 | + pub fn toggle_feed_enabled(&mut self, index: usize) { | ||
| 122 | + if let Some(feed) = self.feeds.get_mut(index) { | ||
| 123 | + feed.enabled = !feed.enabled; | ||
| 124 | + let status = if feed.enabled { "activé" } else { "désactivé" }; | ||
| 125 | + self.info_message = Some(format!("Flux {}", status)); | ||
| 126 | + } | ||
| 127 | + } | ||
| 128 | + | ||
| 129 | + /// Sets an error message | ||
| 130 | + pub fn set_error(&mut self, message: impl Into<String>) { | ||
| 131 | + self.error_message = Some(message.into()); | ||
| 132 | + } | ||
| 133 | + | ||
| 134 | + /// Sets an info message | ||
| 135 | + pub fn set_info(&mut self, message: impl Into<String>) { | ||
| 136 | + self.info_message = Some(message.into()); | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + /// Clears all messages | ||
| 140 | + pub fn clear_messages(&mut self) { | ||
| 141 | + self.error_message = None; | ||
| 142 | + self.info_message = None; | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + /// Clears the error message | ||
| 146 | + pub fn clear_error(&mut self) { | ||
| 147 | + self.error_message = None; | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | + /// Clears the info message | ||
| 151 | + pub fn clear_info(&mut self) { | ||
| 152 | + self.info_message = None; | ||
| 153 | + } | ||
| 154 | + | ||
| 155 | + /// Returns the number of feeds | ||
| 156 | + pub fn feed_count(&self) -> usize { | ||
| 157 | + self.feeds.len() | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + /// Returns the number of enabled feeds | ||
| 161 | + pub fn enabled_feed_count(&self) -> usize { | ||
| 162 | + self.feeds.iter().filter(|f| f.enabled).count() | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + /// Returns true if there are any feeds configured | ||
| 166 | + pub fn has_feeds(&self) -> bool { | ||
| 167 | + !self.feeds.is_empty() | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + /// Returns true if synchronization is in progress | ||
| 171 | + pub fn is_syncing(&self) -> bool { | ||
| 172 | + self.sync_in_progress | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + /// Sets the synchronization state | ||
| 176 | + pub fn set_sync_state(&mut self, syncing: bool) { | ||
| 177 | + self.sync_in_progress = syncing; | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + /// Updates the download progress for a feed | ||
| 181 | + pub fn update_download_progress(&mut self, feed_id: &str, progress: f32) { | ||
| 182 | + self.download_progress.insert(feed_id.to_string(), progress.clamp(0.0, 1.0)); | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + /// Removes the download progress for a feed | ||
| 186 | + pub fn remove_download_progress(&mut self, feed_id: &str) { | ||
| 187 | + self.download_progress.remove(feed_id); | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + /// Gets the download progress for a feed | ||
| 191 | + pub fn get_download_progress(&self, feed_id: &str) -> f32 { | ||
| 192 | + *self.download_progress.get(feed_id).unwrap_or(&0.0) | ||
| 193 | + } | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +#[cfg(test)] | ||
| 197 | +mod tests { | ||
| 198 | + use super::*; | ||
| 199 | + use crate::config::feed::Feed; | ||
| 200 | + | ||
| 201 | + #[test] | ||
| 202 | + fn test_app_state_new() { | ||
| 203 | + let app_state = AppState::new("test/config.json"); | ||
| 204 | + assert!(app_state.feeds.is_empty()); | ||
| 205 | + assert!(app_state.current_feed.is_none()); | ||
| 206 | + assert!(!app_state.sync_in_progress); | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + #[test] | ||
| 210 | + fn test_app_state_default_config_path_windows() { | ||
| 211 | + // This test just verifies the function doesn't panic | ||
| 212 | + let path = AppState::default_config_path(); | ||
| 213 | + assert!(!path.to_string_lossy().is_empty()); | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + #[test] | ||
| 217 | + fn test_add_feed() { | ||
| 218 | + let mut app_state = AppState::new("test/config.json"); | ||
| 219 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | ||
| 220 | + | ||
| 221 | + app_state.add_feed(feed); | ||
| 222 | + | ||
| 223 | + assert_eq!(app_state.feeds.len(), 1); | ||
| 224 | + assert_eq!(app_state.feeds[0].name, "Test Podcast"); | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + #[test] | ||
| 228 | + fn test_remove_feed() { | ||
| 229 | + let mut app_state = AppState::new("test/config.json"); | ||
| 230 | + let feed1 = Feed::new("https://example.com/rss1", "Podcast 1", "/downloads1"); | ||
| 231 | + let feed2 = Feed::new("https://example.com/rss2", "Podcast 2", "/downloads2"); | ||
| 232 | + | ||
| 233 | + app_state.add_feed(feed1); | ||
| 234 | + app_state.add_feed(feed2); | ||
| 235 | + | ||
| 236 | + let removed = app_state.remove_feed(0); | ||
| 237 | + assert!(removed.is_some()); | ||
| 238 | + assert_eq!(removed.unwrap().name, "Podcast 1"); | ||
| 239 | + assert_eq!(app_state.feeds.len(), 1); | ||
| 240 | + assert_eq!(app_state.feeds[0].name, "Podcast 2"); | ||
| 241 | + } | ||
| 242 | + | ||
| 243 | + #[test] | ||
| 244 | + fn test_select_feed() { | ||
| 245 | + let mut app_state = AppState::new("test/config.json"); | ||
| 246 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | ||
| 247 | + | ||
| 248 | + app_state.add_feed(feed); | ||
| 249 | + app_state.select_feed(Some(0)); | ||
| 250 | + | ||
| 251 | + assert_eq!(app_state.current_feed, Some(0)); | ||
| 252 | + assert_eq!(app_state.get_current_feed().unwrap().name, "Test Podcast"); | ||
| 253 | + } | ||
| 254 | + | ||
| 255 | + #[test] | ||
| 256 | + fn test_toggle_feed_enabled() { | ||
| 257 | + let mut app_state = AppState::new("test/config.json"); | ||
| 258 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | ||
| 259 | + | ||
| 260 | + app_state.add_feed(feed); | ||
| 261 | + | ||
| 262 | + // Initially enabled | ||
| 263 | + assert!(app_state.feeds[0].enabled); | ||
| 264 | + | ||
| 265 | + // Toggle to disabled | ||
| 266 | + app_state.toggle_feed_enabled(0); | ||
| 267 | + assert!(!app_state.feeds[0].enabled); | ||
| 268 | + | ||
| 269 | + // Toggle back to enabled | ||
| 270 | + app_state.toggle_feed_enabled(0); | ||
| 271 | + assert!(app_state.feeds[0].enabled); | ||
| 272 | + } | ||
| 273 | + | ||
| 274 | + #[test] | ||
| 275 | + fn test_feed_count() { | ||
| 276 | + let mut app_state = AppState::new("test/config.json"); | ||
| 277 | + assert_eq!(app_state.feed_count(), 0); | ||
| 278 | + | ||
| 279 | + app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); | ||
| 280 | + assert_eq!(app_state.feed_count(), 1); | ||
| 281 | + | ||
| 282 | + app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); | ||
| 283 | + assert_eq!(app_state.feed_count(), 2); | ||
| 284 | + } | ||
| 285 | + | ||
| 286 | + #[test] | ||
| 287 | + fn test_enabled_feed_count() { | ||
| 288 | + let mut app_state = AppState::new("test/config.json"); | ||
| 289 | + | ||
| 290 | + app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled | ||
| 291 | + app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled | ||
| 292 | + app_state.add_feed(Feed::new("url3", "Podcast 3", "path3")); // will be disabled | ||
| 293 | + | ||
| 294 | + app_state.feeds[2].enabled = false; | ||
| 295 | + | ||
| 296 | + assert_eq!(app_state.enabled_feed_count(), 2); | ||
| 297 | + } | ||
| 298 | + | ||
| 299 | + #[test] | ||
| 300 | + fn test_messages() { | ||
| 301 | + let mut app_state = AppState::new("test/config.json"); | ||
| 302 | + | ||
| 303 | + assert!(app_state.error_message.is_none()); | ||
| 304 | + assert!(app_state.info_message.is_none()); | ||
| 305 | + | ||
| 306 | + app_state.set_error("Test error"); | ||
| 307 | + assert_eq!(app_state.error_message, Some("Test error".to_string())); | ||
| 308 | + | ||
| 309 | + app_state.set_info("Test info"); | ||
| 310 | + assert_eq!(app_state.info_message, Some("Test info".to_string())); | ||
| 311 | + | ||
| 312 | + app_state.clear_messages(); | ||
| 313 | + assert!(app_state.error_message.is_none()); | ||
| 314 | + assert!(app_state.info_message.is_none()); | ||
| 315 | + } | ||
| 316 | + | ||
| 317 | + #[test] | ||
| 318 | + fn test_download_progress() { | ||
| 319 | + let mut app_state = AppState::new("test/config.json"); | ||
| 320 | + | ||
| 321 | + app_state.update_download_progress("feed-1", 0.5); | ||
| 322 | + assert_eq!(app_state.get_download_progress("feed-1"), 0.5); | ||
| 323 | + | ||
| 324 | + // Test clamping | ||
| 325 | + app_state.update_download_progress("feed-2", 1.5); | ||
| 326 | + assert_eq!(app_state.get_download_progress("feed-2"), 1.0); | ||
| 327 | + | ||
| 328 | + app_state.update_download_progress("feed-3", -0.5); | ||
| 329 | + assert_eq!(app_state.get_download_progress("feed-3"), 0.0); | ||
| 330 | + | ||
| 331 | + // Non-existent feed | ||
| 332 | + assert_eq!(app_state.get_download_progress("feed-4"), 0.0); | ||
| 333 | + } | ||
| 334 | +} | ||
added
src/config/episode.rs +263 -0 | new file mode 100644 | ||
| @@ -0,0 +1,263 @@ | ||
| 1 | +//! Episode module - Podcast episode model | |
| 2 | +//! | |
| 3 | +//! This module defines the Episode structure and related functionality. | |
| 4 | + | |
| 5 | +use chrono::{DateTime, Utc}; | |
| 6 | +use serde::{Deserialize, Serialize}; | |
| 7 | +use std::path::PathBuf; | |
| 8 | +use uuid::Uuid; | |
| 9 | + | |
| 10 | +/// Represents a podcast episode extracted from an RSS feed | |
| 11 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | |
| 12 | +pub struct Episode { | |
| 13 | + /// Unique identifier for the episode (GUID from RSS or generated UUID) | |
| 14 | + pub id: String, | |
| 15 | + /// Episode title | |
| 16 | + pub title: String, | |
| 17 | + /// Episode description (optional) | |
| 18 | + pub description: Option<String>, | |
| 19 | + /// Publication date of the episode | |
| 20 | + pub pub_date: DateTime<Utc>, | |
| 21 | + /// URL to download the audio file | |
| 22 | + pub audio_url: String, | |
| 23 | + /// Filename for the downloaded audio file | |
| 24 | + pub audio_filename: String, | |
| 25 | + /// Whether the episode has been downloaded | |
| 26 | + pub downloaded: bool, | |
| 27 | + /// File size in bytes (if available) | |
| 28 | + pub file_size: Option<u64>, | |
| 29 | + /// MIME type of the audio file (optional) | |
| 30 | + pub mime_type: Option<String>, | |
| 31 | + /// Duration of the episode in seconds (optional) | |
| 32 | + pub duration_seconds: Option<u64>, | |
| 33 | +} | |
| 34 | + | |
| 35 | +impl Default for Episode { | |
| 36 | + fn default() -> Self { | |
| 37 | + Self { | |
| 38 | + id: Uuid::new_v4().to_string(), | |
| 39 | + title: String::new(), | |
| 40 | + description: None, | |
| 41 | + pub_date: Utc::now(), | |
| 42 | + audio_url: String::new(), | |
| 43 | + audio_filename: String::new(), | |
| 44 | + downloaded: false, | |
| 45 | + file_size: None, | |
| 46 | + mime_type: None, | |
| 47 | + duration_seconds: None, | |
| 48 | + } | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +impl Episode { | |
| 53 | + /// Creates a new Episode with the given parameters | |
| 54 | + pub fn new( | |
| 55 | + id: impl Into<String>, | |
| 56 | + title: impl Into<String>, | |
| 57 | + audio_url: impl Into<String>, | |
| 58 | + pub_date: DateTime<Utc>, | |
| 59 | + ) -> Self { | |
| 60 | + // Extract filename from URL if audio_filename is not provided | |
| 61 | + let audio_url_str = audio_url.as_ref(); | |
| 62 | + let filename = PathBuf::from(audio_url_str) | |
| 63 | + .file_name() | |
| 64 | + .and_then(|s| s.to_str()) | |
| 65 | + .unwrap_or("podcast_episode") | |
| 66 | + .to_string(); | |
| 67 | + | |
| 68 | + Self { | |
| 69 | + id: id.into(), | |
| 70 | + title: title.into(), | |
| 71 | + description: None, | |
| 72 | + pub_date, | |
| 73 | + audio_url: audio_url.into(), | |
| 74 | + audio_filename: filename, | |
| 75 | + downloaded: false, | |
| 76 | + file_size: None, | |
| 77 | + mime_type: None, | |
| 78 | + duration_seconds: None, | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + /// Creates a new Episode with a custom filename | |
| 83 | + pub fn with_filename( | |
| 84 | + id: impl Into<String>, | |
| 85 | + title: impl Into<String>, | |
| 86 | + audio_url: impl Into<String>, | |
| 87 | + pub_date: DateTime<Utc>, | |
| 88 | + audio_filename: impl Into<String>, | |
| 89 | + ) -> Self { | |
| 90 | + Self { | |
| 91 | + id: id.into(), | |
| 92 | + title: title.into(), | |
| 93 | + description: None, | |
| 94 | + pub_date, | |
| 95 | + audio_url: audio_url.into(), | |
| 96 | + audio_filename: audio_filename.into(), | |
| 97 | + downloaded: false, | |
| 98 | + file_size: None, | |
| 99 | + mime_type: None, | |
| 100 | + duration_seconds: None, | |
| 101 | + } | |
| 102 | + } | |
| 103 | + | |
| 104 | + /// Returns the full path where the episode should be saved | |
| 105 | + pub fn get_save_path(&self, download_dir: &PathBuf) -> PathBuf { | |
| 106 | + download_dir.join(&self.audio_filename) | |
| 107 | + } | |
| 108 | + | |
| 109 | + /// Marks the episode as downloaded | |
| 110 | + pub fn mark_as_downloaded(&mut self) { | |
| 111 | + self.downloaded = true; | |
| 112 | + } | |
| 113 | + | |
| 114 | + /// Returns true if the episode is an audio file based on URL | |
| 115 | + pub fn is_audio_url(&self) -> bool { | |
| 116 | + let url = self.audio_url.to_lowercase(); | |
| 117 | + url.ends_with(".mp3") | |
| 118 | + || url.ends_with(".m4a") | |
| 119 | + || url.ends_with(".aac") | |
| 120 | + || url.ends_with(".wav") | |
| 121 | + || url.ends_with(".ogg") | |
| 122 | + || url.ends_with(".flac") | |
| 123 | + || url.ends_with(".opus") | |
| 124 | + } | |
| 125 | + | |
| 126 | + /// Formats the duration as a human-readable string (HH:MM:SS) | |
| 127 | + pub fn format_duration(&self) -> String { | |
| 128 | + match self.duration_seconds { | |
| 129 | + Some(seconds) => { | |
| 130 | + let hours = seconds / 3600; | |
| 131 | + let minutes = (seconds % 3600) / 60; | |
| 132 | + let secs = seconds % 60; | |
| 133 | + if hours > 0 { | |
| 134 | + format!("{:02}:{:02}:{:02}", hours, minutes, secs) | |
| 135 | + } else { | |
| 136 | + format!("{:02}:{:02}", minutes, secs) | |
| 137 | + } | |
| 138 | + } | |
| 139 | + None => "Unknown".to_string(), | |
| 140 | + } | |
| 141 | + } | |
| 142 | + | |
| 143 | + /// Formats the file size as a human-readable string | |
| 144 | + pub fn format_file_size(&self) -> String { | |
| 145 | + match self.file_size { | |
| 146 | + Some(bytes) => { | |
| 147 | + if bytes < 1024 { | |
| 148 | + format!("{} B", bytes) | |
| 149 | + } else if bytes < 1024 * 1024 { | |
| 150 | + format!(" {:.2} KB", bytes as f64 / 1024.0) | |
| 151 | + } else if bytes < 1024 * 1024 * 1024 { | |
| 152 | + format!(" {:.2} MB", bytes as f64 / (1024.0 * 1024.0)) | |
| 153 | + } else { | |
| 154 | + format!(" {:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) | |
| 155 | + } | |
| 156 | + } | |
| 157 | + None => "Unknown".to_string(), | |
| 158 | + } | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +#[cfg(test)] | |
| 163 | +mod tests { | |
| 164 | + use super::*; | |
| 165 | + use chrono::TimeZone; | |
| 166 | + | |
| 167 | + #[test] | |
| 168 | + fn test_episode_default() { | |
| 169 | + let episode = Episode::default(); | |
| 170 | + assert!(!episode.id.is_empty()); | |
| 171 | + assert!(episode.title.is_empty()); | |
| 172 | + assert!(!episode.downloaded); | |
| 173 | + } | |
| 174 | + | |
| 175 | + #[test] | |
| 176 | + fn test_episode_new() { | |
| 177 | + let pub_date = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); | |
| 178 | + let episode = Episode::new( | |
| 179 | + "ep-123", | |
| 180 | + "Test Episode", | |
| 181 | + "https://example.com/episode.mp3", | |
| 182 | + pub_date, | |
| 183 | + ); | |
| 184 | + | |
| 185 | + assert_eq!(episode.id, "ep-123"); | |
| 186 | + assert_eq!(episode.title, "Test Episode"); | |
| 187 | + assert_eq!(episode.audio_url, "https://example.com/episode.mp3"); | |
| 188 | + assert_eq!(episode.audio_filename, "episode.mp3"); | |
| 189 | + assert!(!episode.downloaded); | |
| 190 | + } | |
| 191 | + | |
| 192 | + #[test] | |
| 193 | + fn test_episode_new_extracts_filename() { | |
| 194 | + let pub_date = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); | |
| 195 | + let episode = Episode::new( | |
| 196 | + "ep-123", | |
| 197 | + "Test", | |
| 198 | + "https://example.com/path/to/episode.mp3", | |
| 199 | + pub_date, | |
| 200 | + ); | |
| 201 | + assert_eq!(episode.audio_filename, "episode.mp3"); | |
| 202 | + } | |
| 203 | + | |
| 204 | + #[test] | |
| 205 | + fn test_get_save_path() { | |
| 206 | + let pub_date = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); | |
| 207 | + let episode = Episode::new("ep-123", "Test", "https://example.com/episode.mp3", pub_date); | |
| 208 | + let download_dir = PathBuf::from("/downloads/podcasts"); | |
| 209 | + | |
| 210 | + let save_path = episode.get_save_path(&download_dir); | |
| 211 | + assert_eq!(save_path, PathBuf::from("/downloads/podcasts/episode.mp3")); | |
| 212 | + } | |
| 213 | + | |
| 214 | + #[test] | |
| 215 | + fn test_is_audio_url() { | |
| 216 | + let pub_date = Utc::now(); | |
| 217 | + | |
| 218 | + let mp3_episode = Episode::new("1", "Test", "https://example.com/episode.mp3", pub_date); | |
| 219 | + assert!(mp3_episode.is_audio_url()); | |
| 220 | + | |
| 221 | + let m4a_episode = Episode::new("2", "Test", "https://example.com/episode.m4a", pub_date); | |
| 222 | + assert!(m4a_episode.is_audio_url()); | |
| 223 | + | |
| 224 | + let non_audio = Episode::new("3", "Test", "https://example.com/episode.txt", pub_date); | |
| 225 | + assert!(!non_audio.is_audio_url()); | |
| 226 | + } | |
| 227 | + | |
| 228 | + #[test] | |
| 229 | + fn test_format_duration() { | |
| 230 | + let pub_date = Utc::now(); | |
| 231 | + let mut episode = Episode::new("1", "Test", "url", pub_date); | |
| 232 | + | |
| 233 | + episode.duration_seconds = Some(3661); // 1h 1m 1s | |
| 234 | + assert_eq!(episode.format_duration(), "01:01:01"); | |
| 235 | + | |
| 236 | + episode.duration_seconds = Some(125); // 2m 5s | |
| 237 | + assert_eq!(episode.format_duration(), "02:05"); | |
| 238 | + | |
| 239 | + episode.duration_seconds = None; | |
| 240 | + assert_eq!(episode.format_duration(), "Unknown"); | |
| 241 | + } | |
| 242 | + | |
| 243 | + #[test] | |
| 244 | + fn test_format_file_size() { | |
| 245 | + let pub_date = Utc::now(); | |
| 246 | + let mut episode = Episode::new("1", "Test", "url", pub_date); | |
| 247 | + | |
| 248 | + episode.file_size = Some(512); | |
| 249 | + assert_eq!(episode.format_file_size(), "512 B"); | |
| 250 | + | |
| 251 | + episode.file_size = Some(1536); | |
| 252 | + assert_eq!(episode.format_file_size(), " 1.50 KB"); | |
| 253 | + | |
| 254 | + episode.file_size = Some(1_572_864); // ~1.5 MB | |
| 255 | + assert_eq!(episode.format_file_size(), " 1.50 MB"); | |
| 256 | + | |
| 257 | + episode.file_size = Some(1_610_612_736); // ~1.5 GB | |
| 258 | + assert_eq!(episode.format_file_size(), " 1.50 GB"); | |
| 259 | + | |
| 260 | + episode.file_size = None; | |
| 261 | + assert_eq!(episode.format_file_size(), "Unknown"); | |
| 262 | + } | |
| 263 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,263 @@ | |||
| 1 | +//! Episode module - Podcast episode model | ||
| 2 | +//! | ||
| 3 | +//! This module defines the Episode structure and related functionality. | ||
| 4 | + | ||
| 5 | +use chrono::{DateTime, Utc}; | ||
| 6 | +use serde::{Deserialize, Serialize}; | ||
| 7 | +use std::path::PathBuf; | ||
| 8 | +use uuid::Uuid; | ||
| 9 | + | ||
| 10 | +/// Represents a podcast episode extracted from an RSS feed | ||
| 11 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||
| 12 | +pub struct Episode { | ||
| 13 | + /// Unique identifier for the episode (GUID from RSS or generated UUID) | ||
| 14 | + pub id: String, | ||
| 15 | + /// Episode title | ||
| 16 | + pub title: String, | ||
| 17 | + /// Episode description (optional) | ||
| 18 | + pub description: Option<String>, | ||
| 19 | + /// Publication date of the episode | ||
| 20 | + pub pub_date: DateTime<Utc>, | ||
| 21 | + /// URL to download the audio file | ||
| 22 | + pub audio_url: String, | ||
| 23 | + /// Filename for the downloaded audio file | ||
| 24 | + pub audio_filename: String, | ||
| 25 | + /// Whether the episode has been downloaded | ||
| 26 | + pub downloaded: bool, | ||
| 27 | + /// File size in bytes (if available) | ||
| 28 | + pub file_size: Option<u64>, | ||
| 29 | + /// MIME type of the audio file (optional) | ||
| 30 | + pub mime_type: Option<String>, | ||
| 31 | + /// Duration of the episode in seconds (optional) | ||
| 32 | + pub duration_seconds: Option<u64>, | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +impl Default for Episode { | ||
| 36 | + fn default() -> Self { | ||
| 37 | + Self { | ||
| 38 | + id: Uuid::new_v4().to_string(), | ||
| 39 | + title: String::new(), | ||
| 40 | + description: None, | ||
| 41 | + pub_date: Utc::now(), | ||
| 42 | + audio_url: String::new(), | ||
| 43 | + audio_filename: String::new(), | ||
| 44 | + downloaded: false, | ||
| 45 | + file_size: None, | ||
| 46 | + mime_type: None, | ||
| 47 | + duration_seconds: None, | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +impl Episode { | ||
| 53 | + /// Creates a new Episode with the given parameters | ||
| 54 | + pub fn new( | ||
| 55 | + id: impl Into<String>, | ||
| 56 | + title: impl Into<String>, | ||
| 57 | + audio_url: impl Into<String>, | ||
| 58 | + pub_date: DateTime<Utc>, | ||
| 59 | + ) -> Self { | ||
| 60 | + // Extract filename from URL if audio_filename is not provided | ||
| 61 | + let audio_url_str = audio_url.as_ref(); | ||
| 62 | + let filename = PathBuf::from(audio_url_str) | ||
| 63 | + .file_name() | ||
| 64 | + .and_then(|s| s.to_str()) | ||
| 65 | + .unwrap_or("podcast_episode") | ||
| 66 | + .to_string(); | ||
| 67 | + | ||
| 68 | + Self { | ||
| 69 | + id: id.into(), | ||
| 70 | + title: title.into(), | ||
| 71 | + description: None, | ||
| 72 | + pub_date, | ||
| 73 | + audio_url: audio_url.into(), | ||
| 74 | + audio_filename: filename, | ||
| 75 | + downloaded: false, | ||
| 76 | + file_size: None, | ||
| 77 | + mime_type: None, | ||
| 78 | + duration_seconds: None, | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + /// Creates a new Episode with a custom filename | ||
| 83 | + pub fn with_filename( | ||
| 84 | + id: impl Into<String>, | ||
| 85 | + title: impl Into<String>, | ||
| 86 | + audio_url: impl Into<String>, | ||
| 87 | + pub_date: DateTime<Utc>, | ||
| 88 | + audio_filename: impl Into<String>, | ||
| 89 | + ) -> Self { | ||
| 90 | + Self { | ||
| 91 | + id: id.into(), | ||
| 92 | + title: title.into(), | ||
| 93 | + description: None, | ||
| 94 | + pub_date, | ||
| 95 | + audio_url: audio_url.into(), | ||
| 96 | + audio_filename: audio_filename.into(), | ||
| 97 | + downloaded: false, | ||
| 98 | + file_size: None, | ||
| 99 | + mime_type: None, | ||
| 100 | + duration_seconds: None, | ||
| 101 | + } | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + /// Returns the full path where the episode should be saved | ||
| 105 | + pub fn get_save_path(&self, download_dir: &PathBuf) -> PathBuf { | ||
| 106 | + download_dir.join(&self.audio_filename) | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + /// Marks the episode as downloaded | ||
| 110 | + pub fn mark_as_downloaded(&mut self) { | ||
| 111 | + self.downloaded = true; | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + /// Returns true if the episode is an audio file based on URL | ||
| 115 | + pub fn is_audio_url(&self) -> bool { | ||
| 116 | + let url = self.audio_url.to_lowercase(); | ||
| 117 | + url.ends_with(".mp3") | ||
| 118 | + || url.ends_with(".m4a") | ||
| 119 | + || url.ends_with(".aac") | ||
| 120 | + || url.ends_with(".wav") | ||
| 121 | + || url.ends_with(".ogg") | ||
| 122 | + || url.ends_with(".flac") | ||
| 123 | + || url.ends_with(".opus") | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + /// Formats the duration as a human-readable string (HH:MM:SS) | ||
| 127 | + pub fn format_duration(&self) -> String { | ||
| 128 | + match self.duration_seconds { | ||
| 129 | + Some(seconds) => { | ||
| 130 | + let hours = seconds / 3600; | ||
| 131 | + let minutes = (seconds % 3600) / 60; | ||
| 132 | + let secs = seconds % 60; | ||
| 133 | + if hours > 0 { | ||
| 134 | + format!("{:02}:{:02}:{:02}", hours, minutes, secs) | ||
| 135 | + } else { | ||
| 136 | + format!("{:02}:{:02}", minutes, secs) | ||
| 137 | + } | ||
| 138 | + } | ||
| 139 | + None => "Unknown".to_string(), | ||
| 140 | + } | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + /// Formats the file size as a human-readable string | ||
| 144 | + pub fn format_file_size(&self) -> String { | ||
| 145 | + match self.file_size { | ||
| 146 | + Some(bytes) => { | ||
| 147 | + if bytes < 1024 { | ||
| 148 | + format!("{} B", bytes) | ||
| 149 | + } else if bytes < 1024 * 1024 { | ||
| 150 | + format!(" {:.2} KB", bytes as f64 / 1024.0) | ||
| 151 | + } else if bytes < 1024 * 1024 * 1024 { | ||
| 152 | + format!(" {:.2} MB", bytes as f64 / (1024.0 * 1024.0)) | ||
| 153 | + } else { | ||
| 154 | + format!(" {:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) | ||
| 155 | + } | ||
| 156 | + } | ||
| 157 | + None => "Unknown".to_string(), | ||
| 158 | + } | ||
| 159 | + } | ||
| 160 | +} | ||
| 161 | + | ||
| 162 | +#[cfg(test)] | ||
| 163 | +mod tests { | ||
| 164 | + use super::*; | ||
| 165 | + use chrono::TimeZone; | ||
| 166 | + | ||
| 167 | + #[test] | ||
| 168 | + fn test_episode_default() { | ||
| 169 | + let episode = Episode::default(); | ||
| 170 | + assert!(!episode.id.is_empty()); | ||
| 171 | + assert!(episode.title.is_empty()); | ||
| 172 | + assert!(!episode.downloaded); | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + #[test] | ||
| 176 | + fn test_episode_new() { | ||
| 177 | + let pub_date = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); | ||
| 178 | + let episode = Episode::new( | ||
| 179 | + "ep-123", | ||
| 180 | + "Test Episode", | ||
| 181 | + "https://example.com/episode.mp3", | ||
| 182 | + pub_date, | ||
| 183 | + ); | ||
| 184 | + | ||
| 185 | + assert_eq!(episode.id, "ep-123"); | ||
| 186 | + assert_eq!(episode.title, "Test Episode"); | ||
| 187 | + assert_eq!(episode.audio_url, "https://example.com/episode.mp3"); | ||
| 188 | + assert_eq!(episode.audio_filename, "episode.mp3"); | ||
| 189 | + assert!(!episode.downloaded); | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + #[test] | ||
| 193 | + fn test_episode_new_extracts_filename() { | ||
| 194 | + let pub_date = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); | ||
| 195 | + let episode = Episode::new( | ||
| 196 | + "ep-123", | ||
| 197 | + "Test", | ||
| 198 | + "https://example.com/path/to/episode.mp3", | ||
| 199 | + pub_date, | ||
| 200 | + ); | ||
| 201 | + assert_eq!(episode.audio_filename, "episode.mp3"); | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | + #[test] | ||
| 205 | + fn test_get_save_path() { | ||
| 206 | + let pub_date = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); | ||
| 207 | + let episode = Episode::new("ep-123", "Test", "https://example.com/episode.mp3", pub_date); | ||
| 208 | + let download_dir = PathBuf::from("/downloads/podcasts"); | ||
| 209 | + | ||
| 210 | + let save_path = episode.get_save_path(&download_dir); | ||
| 211 | + assert_eq!(save_path, PathBuf::from("/downloads/podcasts/episode.mp3")); | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + #[test] | ||
| 215 | + fn test_is_audio_url() { | ||
| 216 | + let pub_date = Utc::now(); | ||
| 217 | + | ||
| 218 | + let mp3_episode = Episode::new("1", "Test", "https://example.com/episode.mp3", pub_date); | ||
| 219 | + assert!(mp3_episode.is_audio_url()); | ||
| 220 | + | ||
| 221 | + let m4a_episode = Episode::new("2", "Test", "https://example.com/episode.m4a", pub_date); | ||
| 222 | + assert!(m4a_episode.is_audio_url()); | ||
| 223 | + | ||
| 224 | + let non_audio = Episode::new("3", "Test", "https://example.com/episode.txt", pub_date); | ||
| 225 | + assert!(!non_audio.is_audio_url()); | ||
| 226 | + } | ||
| 227 | + | ||
| 228 | + #[test] | ||
| 229 | + fn test_format_duration() { | ||
| 230 | + let pub_date = Utc::now(); | ||
| 231 | + let mut episode = Episode::new("1", "Test", "url", pub_date); | ||
| 232 | + | ||
| 233 | + episode.duration_seconds = Some(3661); // 1h 1m 1s | ||
| 234 | + assert_eq!(episode.format_duration(), "01:01:01"); | ||
| 235 | + | ||
| 236 | + episode.duration_seconds = Some(125); // 2m 5s | ||
| 237 | + assert_eq!(episode.format_duration(), "02:05"); | ||
| 238 | + | ||
| 239 | + episode.duration_seconds = None; | ||
| 240 | + assert_eq!(episode.format_duration(), "Unknown"); | ||
| 241 | + } | ||
| 242 | + | ||
| 243 | + #[test] | ||
| 244 | + fn test_format_file_size() { | ||
| 245 | + let pub_date = Utc::now(); | ||
| 246 | + let mut episode = Episode::new("1", "Test", "url", pub_date); | ||
| 247 | + | ||
| 248 | + episode.file_size = Some(512); | ||
| 249 | + assert_eq!(episode.format_file_size(), "512 B"); | ||
| 250 | + | ||
| 251 | + episode.file_size = Some(1536); | ||
| 252 | + assert_eq!(episode.format_file_size(), " 1.50 KB"); | ||
| 253 | + | ||
| 254 | + episode.file_size = Some(1_572_864); // ~1.5 MB | ||
| 255 | + assert_eq!(episode.format_file_size(), " 1.50 MB"); | ||
| 256 | + | ||
| 257 | + episode.file_size = Some(1_610_612_736); // ~1.5 GB | ||
| 258 | + assert_eq!(episode.format_file_size(), " 1.50 GB"); | ||
| 259 | + | ||
| 260 | + episode.file_size = None; | ||
| 261 | + assert_eq!(episode.format_file_size(), "Unknown"); | ||
| 262 | + } | ||
| 263 | +} | ||
added
src/config/feed.rs +147 -0 | new file mode 100644 | ||
| @@ -0,0 +1,147 @@ | ||
| 1 | +//! Feed module - RSS Feed configuration model | |
| 2 | +//! | |
| 3 | +//! This module defines the Feed structure and related functionality. | |
| 4 | + | |
| 5 | +use chrono::{DateTime, Utc}; | |
| 6 | +use serde::{Deserialize, Serialize}; | |
| 7 | +use std::path::PathBuf; | |
| 8 | +use uuid::Uuid; | |
| 9 | + | |
| 10 | +/// Represents a configured RSS feed | |
| 11 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | |
| 12 | +pub struct Feed { | |
| 13 | + /// Unique identifier for the feed | |
| 14 | + pub id: String, | |
| 15 | + /// RSS feed URL | |
| 16 | + pub url: String, | |
| 17 | + /// Display name of the podcast (extracted from RSS or custom) | |
| 18 | + pub name: String, | |
| 19 | + /// Directory where podcast episodes will be downloaded | |
| 20 | + pub download_path: PathBuf, | |
| 21 | + /// Timestamp of the last synchronization check | |
| 22 | + pub last_checked: Option<DateTime<Utc>>, | |
| 23 | + /// Date of the last downloaded episode | |
| 24 | + pub last_episode_date: Option<DateTime<Utc>>, | |
| 25 | + /// Whether the feed is enabled for automatic synchronization | |
| 26 | + pub enabled: bool, | |
| 27 | +} | |
| 28 | + | |
| 29 | +impl Default for Feed { | |
| 30 | + fn default() -> Self { | |
| 31 | + Self { | |
| 32 | + id: Uuid::new_v4().to_string(), | |
| 33 | + url: String::new(), | |
| 34 | + name: String::new(), | |
| 35 | + download_path: PathBuf::new(), | |
| 36 | + last_checked: None, | |
| 37 | + last_episode_date: None, | |
| 38 | + enabled: true, | |
| 39 | + } | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +impl Feed { | |
| 44 | + /// Creates a new Feed with the given URL, name, and download path | |
| 45 | + pub fn new(url: impl Into<String>, name: impl Into<String>, download_path: impl Into<PathBuf>) -> Self { | |
| 46 | + Self { | |
| 47 | + id: Uuid::new_v4().to_string(), | |
| 48 | + url: url.into(), | |
| 49 | + name: name.into(), | |
| 50 | + download_path: download_path.into(), | |
| 51 | + last_checked: None, | |
| 52 | + last_episode_date: None, | |
| 53 | + enabled: true, | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + /// Validates the feed URL | |
| 58 | + pub fn validate_url(&self) -> Result<(), String> { | |
| 59 | + if self.url.is_empty() { | |
| 60 | + return Err("URL cannot be empty".to_string()); | |
| 61 | + } | |
| 62 | + | |
| 63 | + // Basic URL validation - check for http:// or https:// prefix | |
| 64 | + if !self.url.starts_with("http://") && !self.url.starts_with("https://") { | |
| 65 | + return Err("URL must start with http:// or https://".to_string()); | |
| 66 | + } | |
| 67 | + | |
| 68 | + Ok(()) | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Validates the download path | |
| 72 | + pub fn validate_path(&self) -> Result<(), String> { | |
| 73 | + if self.download_path.to_string_lossy().is_empty() { | |
| 74 | + return Err("Download path cannot be empty".to_string()); | |
| 75 | + } | |
| 76 | + | |
| 77 | + Ok(()) | |
| 78 | + } | |
| 79 | + | |
| 80 | + /// Validates both URL and path | |
| 81 | + pub fn validate(&self) -> Result<(), String> { | |
| 82 | + self.validate_url()?; | |
| 83 | + self.validate_path()?; | |
| 84 | + Ok(()) | |
| 85 | + } | |
| 86 | + | |
| 87 | + /// Returns true if the feed is valid | |
| 88 | + pub fn is_valid(&self) -> bool { | |
| 89 | + self.validate().is_ok() | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +#[cfg(test)] | |
| 94 | +mod tests { | |
| 95 | + use super::*; | |
| 96 | + | |
| 97 | + #[test] | |
| 98 | + fn test_feed_default() { | |
| 99 | + let feed = Feed::default(); | |
| 100 | + assert!(!feed.id.is_empty()); | |
| 101 | + assert!(feed.url.is_empty()); | |
| 102 | + assert!(feed.name.is_empty()); | |
| 103 | + assert!(feed.enabled); | |
| 104 | + } | |
| 105 | + | |
| 106 | + #[test] | |
| 107 | + fn test_feed_new() { | |
| 108 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | |
| 109 | + assert_eq!(feed.url, "https://example.com/rss"); | |
| 110 | + assert_eq!(feed.name, "Test Podcast"); | |
| 111 | + assert_eq!(feed.download_path, PathBuf::from("/downloads")); | |
| 112 | + assert!(feed.enabled); | |
| 113 | + } | |
| 114 | + | |
| 115 | + #[test] | |
| 116 | + fn test_validate_url_valid() { | |
| 117 | + let feed = Feed::new("https://example.com/rss", "Test", "/path"); | |
| 118 | + assert!(feed.validate_url().is_ok()); | |
| 119 | + } | |
| 120 | + | |
| 121 | + #[test] | |
| 122 | + fn test_validate_url_invalid_empty() { | |
| 123 | + let feed = Feed::new("", "Test", "/path"); | |
| 124 | + assert!(feed.validate_url().is_err()); | |
| 125 | + } | |
| 126 | + | |
| 127 | + #[test] | |
| 128 | + fn test_validate_url_invalid_no_protocol() { | |
| 129 | + let feed = Feed::new("example.com/rss", "Test", "/path"); | |
| 130 | + assert!(feed.validate_url().is_err()); | |
| 131 | + } | |
| 132 | + | |
| 133 | + #[test] | |
| 134 | + fn test_validate_path_valid() { | |
| 135 | + let feed = Feed::new("https://example.com/rss", "Test", "/downloads"); | |
| 136 | + assert!(feed.validate_path().is_ok()); | |
| 137 | + } | |
| 138 | + | |
| 139 | + #[test] | |
| 140 | + fn test_is_valid() { | |
| 141 | + let valid_feed = Feed::new("https://example.com/rss", "Test", "/downloads"); | |
| 142 | + assert!(valid_feed.is_valid()); | |
| 143 | + | |
| 144 | + let invalid_feed = Feed::new("", "Test", ""); | |
| 145 | + assert!(!invalid_feed.is_valid()); | |
| 146 | + } | |
| 147 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,147 @@ | |||
| 1 | +//! Feed module - RSS Feed configuration model | ||
| 2 | +//! | ||
| 3 | +//! This module defines the Feed structure and related functionality. | ||
| 4 | + | ||
| 5 | +use chrono::{DateTime, Utc}; | ||
| 6 | +use serde::{Deserialize, Serialize}; | ||
| 7 | +use std::path::PathBuf; | ||
| 8 | +use uuid::Uuid; | ||
| 9 | + | ||
| 10 | +/// Represents a configured RSS feed | ||
| 11 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||
| 12 | +pub struct Feed { | ||
| 13 | + /// Unique identifier for the feed | ||
| 14 | + pub id: String, | ||
| 15 | + /// RSS feed URL | ||
| 16 | + pub url: String, | ||
| 17 | + /// Display name of the podcast (extracted from RSS or custom) | ||
| 18 | + pub name: String, | ||
| 19 | + /// Directory where podcast episodes will be downloaded | ||
| 20 | + pub download_path: PathBuf, | ||
| 21 | + /// Timestamp of the last synchronization check | ||
| 22 | + pub last_checked: Option<DateTime<Utc>>, | ||
| 23 | + /// Date of the last downloaded episode | ||
| 24 | + pub last_episode_date: Option<DateTime<Utc>>, | ||
| 25 | + /// Whether the feed is enabled for automatic synchronization | ||
| 26 | + pub enabled: bool, | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +impl Default for Feed { | ||
| 30 | + fn default() -> Self { | ||
| 31 | + Self { | ||
| 32 | + id: Uuid::new_v4().to_string(), | ||
| 33 | + url: String::new(), | ||
| 34 | + name: String::new(), | ||
| 35 | + download_path: PathBuf::new(), | ||
| 36 | + last_checked: None, | ||
| 37 | + last_episode_date: None, | ||
| 38 | + enabled: true, | ||
| 39 | + } | ||
| 40 | + } | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +impl Feed { | ||
| 44 | + /// Creates a new Feed with the given URL, name, and download path | ||
| 45 | + pub fn new(url: impl Into<String>, name: impl Into<String>, download_path: impl Into<PathBuf>) -> Self { | ||
| 46 | + Self { | ||
| 47 | + id: Uuid::new_v4().to_string(), | ||
| 48 | + url: url.into(), | ||
| 49 | + name: name.into(), | ||
| 50 | + download_path: download_path.into(), | ||
| 51 | + last_checked: None, | ||
| 52 | + last_episode_date: None, | ||
| 53 | + enabled: true, | ||
| 54 | + } | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + /// Validates the feed URL | ||
| 58 | + pub fn validate_url(&self) -> Result<(), String> { | ||
| 59 | + if self.url.is_empty() { | ||
| 60 | + return Err("URL cannot be empty".to_string()); | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + // Basic URL validation - check for http:// or https:// prefix | ||
| 64 | + if !self.url.starts_with("http://") && !self.url.starts_with("https://") { | ||
| 65 | + return Err("URL must start with http:// or https://".to_string()); | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + Ok(()) | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + /// Validates the download path | ||
| 72 | + pub fn validate_path(&self) -> Result<(), String> { | ||
| 73 | + if self.download_path.to_string_lossy().is_empty() { | ||
| 74 | + return Err("Download path cannot be empty".to_string()); | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + Ok(()) | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + /// Validates both URL and path | ||
| 81 | + pub fn validate(&self) -> Result<(), String> { | ||
| 82 | + self.validate_url()?; | ||
| 83 | + self.validate_path()?; | ||
| 84 | + Ok(()) | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + /// Returns true if the feed is valid | ||
| 88 | + pub fn is_valid(&self) -> bool { | ||
| 89 | + self.validate().is_ok() | ||
| 90 | + } | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +#[cfg(test)] | ||
| 94 | +mod tests { | ||
| 95 | + use super::*; | ||
| 96 | + | ||
| 97 | + #[test] | ||
| 98 | + fn test_feed_default() { | ||
| 99 | + let feed = Feed::default(); | ||
| 100 | + assert!(!feed.id.is_empty()); | ||
| 101 | + assert!(feed.url.is_empty()); | ||
| 102 | + assert!(feed.name.is_empty()); | ||
| 103 | + assert!(feed.enabled); | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + #[test] | ||
| 107 | + fn test_feed_new() { | ||
| 108 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | ||
| 109 | + assert_eq!(feed.url, "https://example.com/rss"); | ||
| 110 | + assert_eq!(feed.name, "Test Podcast"); | ||
| 111 | + assert_eq!(feed.download_path, PathBuf::from("/downloads")); | ||
| 112 | + assert!(feed.enabled); | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + #[test] | ||
| 116 | + fn test_validate_url_valid() { | ||
| 117 | + let feed = Feed::new("https://example.com/rss", "Test", "/path"); | ||
| 118 | + assert!(feed.validate_url().is_ok()); | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + #[test] | ||
| 122 | + fn test_validate_url_invalid_empty() { | ||
| 123 | + let feed = Feed::new("", "Test", "/path"); | ||
| 124 | + assert!(feed.validate_url().is_err()); | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + #[test] | ||
| 128 | + fn test_validate_url_invalid_no_protocol() { | ||
| 129 | + let feed = Feed::new("example.com/rss", "Test", "/path"); | ||
| 130 | + assert!(feed.validate_url().is_err()); | ||
| 131 | + } | ||
| 132 | + | ||
| 133 | + #[test] | ||
| 134 | + fn test_validate_path_valid() { | ||
| 135 | + let feed = Feed::new("https://example.com/rss", "Test", "/downloads"); | ||
| 136 | + assert!(feed.validate_path().is_ok()); | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + #[test] | ||
| 140 | + fn test_is_valid() { | ||
| 141 | + let valid_feed = Feed::new("https://example.com/rss", "Test", "/downloads"); | ||
| 142 | + assert!(valid_feed.is_valid()); | ||
| 143 | + | ||
| 144 | + let invalid_feed = Feed::new("", "Test", ""); | ||
| 145 | + assert!(!invalid_feed.is_valid()); | ||
| 146 | + } | ||
| 147 | +} | ||
modified
src/config/mod.rs +8 -2 | @@ -2,7 +2,13 @@ | ||
| 2 | 2 | //! |
| 3 | 3 | //! This module contains the data models and storage logic for the application. |
| 4 | 4 | |
| 5 | -pub mod feed; | |
| 6 | -pub mod episode; | |
| 7 | 5 | pub mod app_state; |
| 6 | +pub mod episode; | |
| 7 | +pub mod feed; | |
| 8 | 8 | pub mod storage; |
| 9 | + | |
| 10 | +// Re-export main types for convenience | |
| 11 | +pub use app_state::AppState; | |
| 12 | +pub use episode::Episode; | |
| 13 | +pub use feed::Feed; | |
| 14 | +pub use storage::{load_feeds, save_feeds, StorageError}; | |
| @@ -2,7 +2,13 @@ | |||
| 2 | //! | 2 | //! |
| 3 | //! This module contains the data models and storage logic for the application. | 3 | //! This module contains the data models and storage logic for the application. |
| 4 | 4 | ||
| 5 | -pub mod feed; | ||
| 6 | -pub mod episode; | ||
| 7 | pub mod app_state; | 5 | pub mod app_state; |
| 6 | +pub mod episode; | ||
| 7 | +pub mod feed; | ||
| 8 | pub mod storage; | 8 | pub mod storage; |
| 9 | + | ||
| 10 | +// Re-export main types for convenience | ||
| 11 | +pub use app_state::AppState; | ||
| 12 | +pub use episode::Episode; | ||
| 13 | +pub use feed::Feed; | ||
| 14 | +pub use storage::{load_feeds, save_feeds, StorageError}; | ||
added
src/config/storage.rs +204 -0 | new file mode 100644 | ||
| @@ -0,0 +1,204 @@ | ||
| 1 | +//! Storage module - Configuration persistence | |
| 2 | +//! | |
| 3 | +//! This module handles saving and loading feed configurations to/from JSON files. | |
| 4 | + | |
| 5 | +use crate::config::feed::Feed; | |
| 6 | +use log::{error, info, warn}; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use std::fs; | |
| 9 | +use std::path::Path; | |
| 10 | +use thiserror::Error; | |
| 11 | + | |
| 12 | +/// Error type for storage operations | |
| 13 | +#[derive(Debug, Error)] | |
| 14 | +pub enum StorageError { | |
| 15 | + /// Failed to read the configuration file | |
| 16 | + #[error("Failed to read configuration file: {0}")] | |
| 17 | + ReadError(String), | |
| 18 | + | |
| 19 | + /// Failed to write the configuration file | |
| 20 | + #[error("Failed to write configuration file: {0}")] | |
| 21 | + WriteError(String), | |
| 22 | + | |
| 23 | + /// Failed to parse the configuration file | |
| 24 | + #[error("Failed to parse configuration file: {0}")] | |
| 25 | + ParseError(String), | |
| 26 | + | |
| 27 | + /// Failed to serialize the configuration | |
| 28 | + #[error("Failed to serialize configuration: {0}")] | |
| 29 | + SerializeError(String), | |
| 30 | + | |
| 31 | + /// Configuration file not found | |
| 32 | + #[error("Configuration file not found: {0}")] | |
| 33 | + NotFound(String), | |
| 34 | + | |
| 35 | + /// Invalid configuration directory | |
| 36 | + #[error("Invalid configuration directory: {0}")] | |
| 37 | + InvalidDirectory(String), | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// Saves the list of feeds to a JSON file | |
| 41 | +/// | |
| 42 | +/// # Arguments | |
| 43 | +/// * `feeds` - The list of feeds to save | |
| 44 | +/// * `path` - The path to the configuration file | |
| 45 | +/// | |
| 46 | +/// # Returns | |
| 47 | +/// * `Ok(())` if successful | |
| 48 | +/// * `Err(StorageError)` if an error occurs | |
| 49 | +pub fn save_feeds(feeds: &[Feed], path: &Path) -> Result<(), StorageError> { | |
| 50 | + info!("Saving {} feeds to {}", feeds.len(), path.display()); | |
| 51 | + | |
| 52 | + // Create parent directories if they don't exist | |
| 53 | + if let Some(parent) = path.parent() { | |
| 54 | + if !parent.exists() { | |
| 55 | + info!("Creating directory: {}", parent.display()); | |
| 56 | + fs::create_dir_all(parent) | |
| 57 | + .map_err(|e| StorageError::InvalidDirectory(format!("{}: {}", parent.display(), e)))?; | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + // Serialize feeds to JSON | |
| 62 | + let json = serde_json::to_string_pretty(feeds) | |
| 63 | + .map_err(|e| StorageError::SerializeError(e.to_string()))?; | |
| 64 | + | |
| 65 | + // Write to file | |
| 66 | + fs::write(path, json) | |
| 67 | + .map_err(|e| StorageError::WriteError(format!("{}: {}", path.display(), e)))?; | |
| 68 | + | |
| 69 | + info!("Successfully saved {} feeds", feeds.len()); | |
| 70 | + Ok(()) | |
| 71 | +} | |
| 72 | + | |
| 73 | +/// Loads the list of feeds from a JSON file | |
| 74 | +/// | |
| 75 | +/// # Arguments | |
| 76 | +/// * `path` - The path to the configuration file | |
| 77 | +/// | |
| 78 | +/// # Returns | |
| 79 | +/// * `Ok(Vec<Feed>)` if successful | |
| 80 | +/// * `Err(StorageError)` if an error occurs | |
| 81 | +pub fn load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError> { | |
| 82 | + info!("Loading feeds from {}", path.display()); | |
| 83 | + | |
| 84 | + // Check if file exists | |
| 85 | + if !path.exists() { | |
| 86 | + warn!("Configuration file not found: {}", path.display()); | |
| 87 | + return Ok(Vec::new()); // Return empty list if file doesn't exist | |
| 88 | + } | |
| 89 | + | |
| 90 | + // Read file content | |
| 91 | + let content = fs::read_to_string(path) | |
| 92 | + .map_err(|e| StorageError::ReadError(format!("{}: {}", path.display(), e)))?; | |
| 93 | + | |
| 94 | + // Parse JSON | |
| 95 | + let feeds: Vec<Feed> = serde_json::from_str(&content) | |
| 96 | + .map_err(|e| StorageError::ParseError(format!("{}: {}", path.display(), e)))?; | |
| 97 | + | |
| 98 | + info!("Successfully loaded {} feeds", feeds.len()); | |
| 99 | + Ok(feeds) | |
| 100 | +} | |
| 101 | + | |
| 102 | +/// Gets the default configuration path | |
| 103 | +/// | |
| 104 | +/// On Windows: %APPDATA%/rss-podcast-dl/config.json | |
| 105 | +/// On Unix: ~/.config/rss-podcast-dl/config.json | |
| 106 | +pub fn get_config_path() -> std::path::PathBuf { | |
| 107 | + // Use the same logic as AppState::default_config_path | |
| 108 | + if cfg!(windows) { | |
| 109 | + if let Some(app_data) = std::env::var_os("APPDATA") { | |
| 110 | + Path::new(&app_data).join("rss-podcast-dl").join("config.json") | |
| 111 | + } else { | |
| 112 | + Path::new("config.json").to_path_buf() | |
| 113 | + } | |
| 114 | + } else { | |
| 115 | + if let Some(home) = std::env::var_os("HOME") { | |
| 116 | + Path::new(&home).join(".config").join("rss-podcast-dl").join("config.json") | |
| 117 | + } else { | |
| 118 | + Path::new("config.json").to_path_buf() | |
| 119 | + } | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +/// Saves feeds to the default configuration path | |
| 124 | +pub fn save_feeds_default(feeds: &[Feed]) -> Result<(), StorageError> { | |
| 125 | + save_feeds(feeds, &get_config_path()) | |
| 126 | +} | |
| 127 | + | |
| 128 | +/// Loads feeds from the default configuration path | |
| 129 | +pub fn load_feeds_default() -> Result<Vec<Feed>, StorageError> { | |
| 130 | + load_feeds(&get_config_path()) | |
| 131 | +} | |
| 132 | + | |
| 133 | +#[cfg(test)] | |
| 134 | +mod tests { | |
| 135 | + use super::*; | |
| 136 | + use crate::config::feed::Feed; | |
| 137 | + use std::io::Write; | |
| 138 | + use tempfile::NamedTempFile; | |
| 139 | + | |
| 140 | + #[test] | |
| 141 | + fn test_save_and_load_feeds() { | |
| 142 | + // Create temporary file | |
| 143 | + let temp_file = NamedTempFile::new().unwrap(); | |
| 144 | + let path = temp_file.path(); | |
| 145 | + | |
| 146 | + // Create test feeds | |
| 147 | + let feeds = vec![ | |
| 148 | + Feed::new("https://example.com/rss1", "Podcast 1", "/downloads1"), | |
| 149 | + Feed::new("https://example.com/rss2", "Podcast 2", "/downloads2"), | |
| 150 | + ]; | |
| 151 | + | |
| 152 | + // Save feeds | |
| 153 | + save_feeds(&feeds, path).unwrap(); | |
| 154 | + | |
| 155 | + // Load feeds | |
| 156 | + let loaded_feeds = load_feeds(path).unwrap(); | |
| 157 | + | |
| 158 | + // Verify | |
| 159 | + assert_eq!(loaded_feeds.len(), 2); | |
| 160 | + assert_eq!(loaded_feeds[0].name, "Podcast 1"); | |
| 161 | + assert_eq!(loaded_feeds[1].name, "Podcast 2"); | |
| 162 | + } | |
| 163 | + | |
| 164 | + #[test] | |
| 165 | + fn test_save_feeds_creates_directory() { | |
| 166 | + // Create a temporary directory path | |
| 167 | + let temp_dir = tempfile::tempdir().unwrap(); | |
| 168 | + let config_path = temp_dir.path().join("nested").join("dir").join("config.json"); | |
| 169 | + | |
| 170 | + // Create test feeds | |
| 171 | + let feeds = vec![Feed::new("https://example.com/rss", "Test", "/downloads")]; | |
| 172 | + | |
| 173 | + // Save feeds - should create parent directories | |
| 174 | + save_feeds(&feeds, &config_path).unwrap(); | |
| 175 | + | |
| 176 | + // Verify file exists | |
| 177 | + assert!(config_path.exists()); | |
| 178 | + } | |
| 179 | + | |
| 180 | + #[test] | |
| 181 | + fn test_load_feeds_not_found() { | |
| 182 | + let path = Path::new("/nonexistent/path/config.json"); | |
| 183 | + let result = load_feeds(path).unwrap(); | |
| 184 | + assert!(result.is_empty()); | |
| 185 | + } | |
| 186 | + | |
| 187 | + #[test] | |
| 188 | + fn test_save_feeds_invalid_directory() { | |
| 189 | + // Try to save to a read-only directory | |
| 190 | + let path = Path::new("/root/readonly/config.json"); | |
| 191 | + let feeds = vec![Feed::new("https://example.com/rss", "Test", "/downloads")]; | |
| 192 | + | |
| 193 | + let result = save_feeds(&feeds, path); | |
| 194 | + // This will likely fail due to permissions | |
| 195 | + assert!(result.is_err()); | |
| 196 | + } | |
| 197 | + | |
| 198 | + #[test] | |
| 199 | + fn test_get_config_path() { | |
| 200 | + let path = get_config_path(); | |
| 201 | + assert!(!path.to_string_lossy().is_empty()); | |
| 202 | + assert!(path.extension().map_or(false, |ext| ext == "json")); | |
| 203 | + } | |
| 204 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,204 @@ | |||
| 1 | +//! Storage module - Configuration persistence | ||
| 2 | +//! | ||
| 3 | +//! This module handles saving and loading feed configurations to/from JSON files. | ||
| 4 | + | ||
| 5 | +use crate::config::feed::Feed; | ||
| 6 | +use log::{error, info, warn}; | ||
| 7 | +use serde::{Deserialize, Serialize}; | ||
| 8 | +use std::fs; | ||
| 9 | +use std::path::Path; | ||
| 10 | +use thiserror::Error; | ||
| 11 | + | ||
| 12 | +/// Error type for storage operations | ||
| 13 | +#[derive(Debug, Error)] | ||
| 14 | +pub enum StorageError { | ||
| 15 | + /// Failed to read the configuration file | ||
| 16 | + #[error("Failed to read configuration file: {0}")] | ||
| 17 | + ReadError(String), | ||
| 18 | + | ||
| 19 | + /// Failed to write the configuration file | ||
| 20 | + #[error("Failed to write configuration file: {0}")] | ||
| 21 | + WriteError(String), | ||
| 22 | + | ||
| 23 | + /// Failed to parse the configuration file | ||
| 24 | + #[error("Failed to parse configuration file: {0}")] | ||
| 25 | + ParseError(String), | ||
| 26 | + | ||
| 27 | + /// Failed to serialize the configuration | ||
| 28 | + #[error("Failed to serialize configuration: {0}")] | ||
| 29 | + SerializeError(String), | ||
| 30 | + | ||
| 31 | + /// Configuration file not found | ||
| 32 | + #[error("Configuration file not found: {0}")] | ||
| 33 | + NotFound(String), | ||
| 34 | + | ||
| 35 | + /// Invalid configuration directory | ||
| 36 | + #[error("Invalid configuration directory: {0}")] | ||
| 37 | + InvalidDirectory(String), | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +/// Saves the list of feeds to a JSON file | ||
| 41 | +/// | ||
| 42 | +/// # Arguments | ||
| 43 | +/// * `feeds` - The list of feeds to save | ||
| 44 | +/// * `path` - The path to the configuration file | ||
| 45 | +/// | ||
| 46 | +/// # Returns | ||
| 47 | +/// * `Ok(())` if successful | ||
| 48 | +/// * `Err(StorageError)` if an error occurs | ||
| 49 | +pub fn save_feeds(feeds: &[Feed], path: &Path) -> Result<(), StorageError> { | ||
| 50 | + info!("Saving {} feeds to {}", feeds.len(), path.display()); | ||
| 51 | + | ||
| 52 | + // Create parent directories if they don't exist | ||
| 53 | + if let Some(parent) = path.parent() { | ||
| 54 | + if !parent.exists() { | ||
| 55 | + info!("Creating directory: {}", parent.display()); | ||
| 56 | + fs::create_dir_all(parent) | ||
| 57 | + .map_err(|e| StorageError::InvalidDirectory(format!("{}: {}", parent.display(), e)))?; | ||
| 58 | + } | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + // Serialize feeds to JSON | ||
| 62 | + let json = serde_json::to_string_pretty(feeds) | ||
| 63 | + .map_err(|e| StorageError::SerializeError(e.to_string()))?; | ||
| 64 | + | ||
| 65 | + // Write to file | ||
| 66 | + fs::write(path, json) | ||
| 67 | + .map_err(|e| StorageError::WriteError(format!("{}: {}", path.display(), e)))?; | ||
| 68 | + | ||
| 69 | + info!("Successfully saved {} feeds", feeds.len()); | ||
| 70 | + Ok(()) | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +/// Loads the list of feeds from a JSON file | ||
| 74 | +/// | ||
| 75 | +/// # Arguments | ||
| 76 | +/// * `path` - The path to the configuration file | ||
| 77 | +/// | ||
| 78 | +/// # Returns | ||
| 79 | +/// * `Ok(Vec<Feed>)` if successful | ||
| 80 | +/// * `Err(StorageError)` if an error occurs | ||
| 81 | +pub fn load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError> { | ||
| 82 | + info!("Loading feeds from {}", path.display()); | ||
| 83 | + | ||
| 84 | + // Check if file exists | ||
| 85 | + if !path.exists() { | ||
| 86 | + warn!("Configuration file not found: {}", path.display()); | ||
| 87 | + return Ok(Vec::new()); // Return empty list if file doesn't exist | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + // Read file content | ||
| 91 | + let content = fs::read_to_string(path) | ||
| 92 | + .map_err(|e| StorageError::ReadError(format!("{}: {}", path.display(), e)))?; | ||
| 93 | + | ||
| 94 | + // Parse JSON | ||
| 95 | + let feeds: Vec<Feed> = serde_json::from_str(&content) | ||
| 96 | + .map_err(|e| StorageError::ParseError(format!("{}: {}", path.display(), e)))?; | ||
| 97 | + | ||
| 98 | + info!("Successfully loaded {} feeds", feeds.len()); | ||
| 99 | + Ok(feeds) | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +/// Gets the default configuration path | ||
| 103 | +/// | ||
| 104 | +/// On Windows: %APPDATA%/rss-podcast-dl/config.json | ||
| 105 | +/// On Unix: ~/.config/rss-podcast-dl/config.json | ||
| 106 | +pub fn get_config_path() -> std::path::PathBuf { | ||
| 107 | + // Use the same logic as AppState::default_config_path | ||
| 108 | + if cfg!(windows) { | ||
| 109 | + if let Some(app_data) = std::env::var_os("APPDATA") { | ||
| 110 | + Path::new(&app_data).join("rss-podcast-dl").join("config.json") | ||
| 111 | + } else { | ||
| 112 | + Path::new("config.json").to_path_buf() | ||
| 113 | + } | ||
| 114 | + } else { | ||
| 115 | + if let Some(home) = std::env::var_os("HOME") { | ||
| 116 | + Path::new(&home).join(".config").join("rss-podcast-dl").join("config.json") | ||
| 117 | + } else { | ||
| 118 | + Path::new("config.json").to_path_buf() | ||
| 119 | + } | ||
| 120 | + } | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +/// Saves feeds to the default configuration path | ||
| 124 | +pub fn save_feeds_default(feeds: &[Feed]) -> Result<(), StorageError> { | ||
| 125 | + save_feeds(feeds, &get_config_path()) | ||
| 126 | +} | ||
| 127 | + | ||
| 128 | +/// Loads feeds from the default configuration path | ||
| 129 | +pub fn load_feeds_default() -> Result<Vec<Feed>, StorageError> { | ||
| 130 | + load_feeds(&get_config_path()) | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +#[cfg(test)] | ||
| 134 | +mod tests { | ||
| 135 | + use super::*; | ||
| 136 | + use crate::config::feed::Feed; | ||
| 137 | + use std::io::Write; | ||
| 138 | + use tempfile::NamedTempFile; | ||
| 139 | + | ||
| 140 | + #[test] | ||
| 141 | + fn test_save_and_load_feeds() { | ||
| 142 | + // Create temporary file | ||
| 143 | + let temp_file = NamedTempFile::new().unwrap(); | ||
| 144 | + let path = temp_file.path(); | ||
| 145 | + | ||
| 146 | + // Create test feeds | ||
| 147 | + let feeds = vec![ | ||
| 148 | + Feed::new("https://example.com/rss1", "Podcast 1", "/downloads1"), | ||
| 149 | + Feed::new("https://example.com/rss2", "Podcast 2", "/downloads2"), | ||
| 150 | + ]; | ||
| 151 | + | ||
| 152 | + // Save feeds | ||
| 153 | + save_feeds(&feeds, path).unwrap(); | ||
| 154 | + | ||
| 155 | + // Load feeds | ||
| 156 | + let loaded_feeds = load_feeds(path).unwrap(); | ||
| 157 | + | ||
| 158 | + // Verify | ||
| 159 | + assert_eq!(loaded_feeds.len(), 2); | ||
| 160 | + assert_eq!(loaded_feeds[0].name, "Podcast 1"); | ||
| 161 | + assert_eq!(loaded_feeds[1].name, "Podcast 2"); | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + #[test] | ||
| 165 | + fn test_save_feeds_creates_directory() { | ||
| 166 | + // Create a temporary directory path | ||
| 167 | + let temp_dir = tempfile::tempdir().unwrap(); | ||
| 168 | + let config_path = temp_dir.path().join("nested").join("dir").join("config.json"); | ||
| 169 | + | ||
| 170 | + // Create test feeds | ||
| 171 | + let feeds = vec![Feed::new("https://example.com/rss", "Test", "/downloads")]; | ||
| 172 | + | ||
| 173 | + // Save feeds - should create parent directories | ||
| 174 | + save_feeds(&feeds, &config_path).unwrap(); | ||
| 175 | + | ||
| 176 | + // Verify file exists | ||
| 177 | + assert!(config_path.exists()); | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + #[test] | ||
| 181 | + fn test_load_feeds_not_found() { | ||
| 182 | + let path = Path::new("/nonexistent/path/config.json"); | ||
| 183 | + let result = load_feeds(path).unwrap(); | ||
| 184 | + assert!(result.is_empty()); | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + #[test] | ||
| 188 | + fn test_save_feeds_invalid_directory() { | ||
| 189 | + // Try to save to a read-only directory | ||
| 190 | + let path = Path::new("/root/readonly/config.json"); | ||
| 191 | + let feeds = vec![Feed::new("https://example.com/rss", "Test", "/downloads")]; | ||
| 192 | + | ||
| 193 | + let result = save_feeds(&feeds, path); | ||
| 194 | + // This will likely fail due to permissions | ||
| 195 | + assert!(result.is_err()); | ||
| 196 | + } | ||
| 197 | + | ||
| 198 | + #[test] | ||
| 199 | + fn test_get_config_path() { | ||
| 200 | + let path = get_config_path(); | ||
| 201 | + assert!(!path.to_string_lossy().is_empty()); | ||
| 202 | + assert!(path.extension().map_or(false, |ext| ext == "json")); | ||
| 203 | + } | ||
| 204 | +} | ||