msansm1/rss-podcast-dlpublic Fork
⑂ 991224e
Commits
⬇ Clone ▾
git clone https://git.rickub.com/msansm1/rss-podcast-dl.git
git clone ssh://git@rickub.com/msansm1/rss-podcast-dl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Phase 2: Modèles et Stockage

msansm1 committed 2026-07-03T21:51:24+02:00 Browse files
991224e parent: 831c616
modified Cargo.lock +2 -0
@@ -2639,6 +2639,8 @@ dependencies = [
26392639 "rss",
26402640 "serde",
26412641 "serde_json",
2642+ "tempfile",
2643+ "thiserror",
26422644 "tokio",
26432645 "uuid",
26442646 "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"] }
3737 # Path handling
3838 pathdiff = "0.2"
3939
40+# Error handling
41+thiserror = "1.0"
42+
43+# Testing utilities
44+tempfile = "3.0"
45+
4046 # Windows API compatibility for eframe 0.24
4147 [target.'cfg(windows)'.dependencies]
4248 winapi = { version = "0.3", features = ["winuser", "windef"] }
@@ -37,6 +37,12 @@ uuid = { version = "1.0", features = ["v4", "serde"] }
37 # Path handling37 # 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.2446 # 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 @@
4242
4343 | ID | Tâche | Priorité | Durée | Fichiers | Dépendances | Statut |
4444 |----|-------|----------|-------|---------|-------------|--------|
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 | ✅ |
5757
5858 **Total Phase 2** : 14 heures (2 jours)
5959
@@ -179,13 +179,13 @@
179179 | Phase | Nombre de Tâches | Durée Totale | Statut |
180180 |-------|------------------|--------------|--------|
181181 | Phase 1 | 8 tâches | 5h | ✅ Terminé |
182-| Phase 2 | 12 tâches | 14h | ⬜ |
182+| Phase 2 | 12 tâches | 14h | ✅ Terminé |
183183 | Phase 3 | 11 tâches | 23h | ⬜ |
184184 | Phase 4 | 17 tâches | 32h | ⬜ |
185185 | Phase 5 | 10 tâches | 16h | ⬜ |
186186 | Phase 6 | 12 tâches | 24h | ⬜ |
187187 | 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 |
189189
190190 ---
191191
@@ -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 @@
5151
5252 ---
5353
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*
68115
69116 ---
70117
@@ -158,23 +205,23 @@
158205 | Phase | Total | Terminées | % Complet | Statut |
159206 |-------|-------|-----------|------------|--------|
160207 | Phase 1 | 8 | 8 | 100% | ✅ Terminé |
161-| Phase 2 | 12 | 0 | 0% | ⏳ À faire |
208+| Phase 2 | 12 | 12 | 100% | ✅ Terminé |
162209 | Phase 3 | 11 | 0 | 0% | ⏳ À faire |
163210 | Phase 4 | 17 | 0 | 0% | ⏳ À faire |
164211 | Phase 5 | 10 | 0 | 0% | ⏳ À faire |
165212 | Phase 6 | 12 | 0 | 0% | ⏳ À faire |
166213 | Phase 7 | 10 | 0 | 0% | ⏳ À faire |
167-| **Total** | **80** | **8** | **10%** | 🟡 En cours |
214+| **Total** | **80** | **20** | **25%** | 🟡 En cours |
168215
169216 ---
170217
171218 ## Prochaines Étapes
172219
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>`
178225
179226 ### Commandes pour vérifier
180227 ```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 modules60+
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 config62+ - *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 Étapes218 ## 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érifier226 ### Commandes pour vérifier
180 ```bash227 ```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 @@
22 //!
33 //! This module contains the data models and storage logic for the application.
44
5-pub mod feed;
6-pub mod episode;
75 pub mod app_state;
6+pub mod episode;
7+pub mod feed;
88 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+}