Skip to main content

par_term/profile/dynamic/
manager.rs

1//! Background fetch manager for dynamic profile sources.
2//!
3//! [`DynamicProfileManager`] spawns tokio tasks that periodically fetch profiles
4//! from remote URLs and sends [`DynamicProfileUpdate`] messages via an mpsc
5//! channel for the main thread to process.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::{Duration, SystemTime};
10use tokio::sync::mpsc;
11
12use par_term_config::{ConflictResolution, DynamicProfileSource};
13
14use crate::profile::dynamic::fetch::fetch_profiles;
15
16/// Message sent from background fetch tasks to the main thread.
17#[derive(Debug, Clone)]
18pub struct DynamicProfileUpdate {
19    /// The source URL that was fetched.
20    pub url: String,
21    /// Successfully parsed profiles (empty on error).
22    pub profiles: Vec<par_term_config::Profile>,
23    /// How to resolve conflicts with local profiles.
24    pub conflict_resolution: ConflictResolution,
25    /// Error message if the fetch failed.
26    pub error: Option<String>,
27}
28
29/// Status of a dynamic profile source.
30#[derive(Debug, Clone)]
31pub struct SourceStatus {
32    /// The source URL.
33    pub url: String,
34    /// Whether this source is enabled.
35    pub enabled: bool,
36    /// When profiles were last successfully fetched.
37    pub last_fetch: Option<SystemTime>,
38    /// Last error message (if any).
39    pub last_error: Option<String>,
40    /// Number of profiles from this source.
41    pub profile_count: usize,
42    /// Whether a fetch is currently in progress.
43    pub fetching: bool,
44}
45
46/// Manages background fetching of dynamic profiles.
47///
48/// Spawns tokio tasks that periodically fetch profiles from remote URLs
49/// and sends updates via an mpsc channel for the main thread to process.
50pub struct DynamicProfileManager {
51    /// Channel receiver for updates from background tasks.
52    pub update_rx: mpsc::UnboundedReceiver<DynamicProfileUpdate>,
53    /// Channel sender (cloned to background tasks).
54    update_tx: mpsc::UnboundedSender<DynamicProfileUpdate>,
55    /// Status of each source, keyed by URL.
56    pub statuses: HashMap<String, SourceStatus>,
57    /// Handles to cancel background tasks.
58    task_handles: Vec<tokio::task::JoinHandle<()>>,
59}
60
61impl DynamicProfileManager {
62    /// Create a new DynamicProfileManager with fresh channels.
63    pub fn new() -> Self {
64        let (update_tx, update_rx) = mpsc::unbounded_channel();
65        Self {
66            update_rx,
67            update_tx,
68            statuses: HashMap::new(),
69            task_handles: Vec::new(),
70        }
71    }
72
73    /// Start background fetch tasks for all enabled sources.
74    ///
75    /// Stops any existing tasks first. For each enabled source:
76    /// 1. Initializes the source status
77    /// 2. Loads cached profiles and sends them via the channel immediately
78    /// 3. Spawns a tokio task that does an initial fetch, then periodic refreshes
79    pub fn start(
80        &mut self,
81        sources: &[DynamicProfileSource],
82        runtime: &Arc<tokio::runtime::Runtime>,
83    ) {
84        use crate::profile::dynamic::cache::read_cache;
85
86        // Cancel existing tasks
87        self.stop();
88
89        for source in sources {
90            if !source.enabled || source.url.is_empty() {
91                continue;
92            }
93
94            // Initialize status
95            self.statuses.insert(
96                source.url.clone(),
97                SourceStatus {
98                    url: source.url.clone(),
99                    enabled: source.enabled,
100                    last_fetch: None,
101                    last_error: None,
102                    profile_count: 0,
103                    fetching: false,
104                },
105            );
106
107            // Load from cache immediately
108            if let Ok((profiles, meta)) = read_cache(&source.url) {
109                let update = DynamicProfileUpdate {
110                    url: source.url.clone(),
111                    profiles,
112                    conflict_resolution: source.conflict_resolution.clone(),
113                    error: None,
114                };
115                let _ = self.update_tx.send(update);
116
117                if let Some(status) = self.statuses.get_mut(&source.url) {
118                    status.last_fetch = Some(meta.last_fetched);
119                    status.profile_count = meta.profile_count;
120                }
121            }
122
123            // Spawn background fetch task
124            let tx = self.update_tx.clone();
125            let source_clone = source.clone();
126            let url_for_log = source.url.clone();
127            let handle = runtime.spawn(async move {
128                // Initial fetch using spawn_blocking since ureq is synchronous
129                let src = source_clone.clone();
130                let conflict = source_clone.conflict_resolution.clone();
131                match tokio::task::spawn_blocking(move || fetch_profiles(&src)).await {
132                    Ok(result) => {
133                        if tx
134                            .send(DynamicProfileUpdate {
135                                url: result.url.clone(),
136                                profiles: result.profiles,
137                                conflict_resolution: conflict,
138                                error: result.error,
139                            })
140                            .is_err()
141                        {
142                            return; // Receiver dropped
143                        }
144                    }
145                    Err(e) => {
146                        log::error!(
147                            "Dynamic profile fetch task panicked for {}: {}",
148                            url_for_log,
149                            e
150                        );
151                    }
152                }
153
154                // Periodic refresh
155                let mut interval =
156                    tokio::time::interval(Duration::from_secs(source_clone.refresh_interval_secs));
157                interval.tick().await; // Skip first immediate tick
158                loop {
159                    interval.tick().await;
160                    let src = source_clone.clone();
161                    let source_clone2 = source_clone.clone();
162                    let tx_clone = tx.clone();
163                    match tokio::task::spawn_blocking(move || fetch_profiles(&src)).await {
164                        Ok(result) => {
165                            if tx_clone
166                                .send(DynamicProfileUpdate {
167                                    url: result.url.clone(),
168                                    profiles: result.profiles,
169                                    conflict_resolution: source_clone2.conflict_resolution.clone(),
170                                    error: result.error,
171                                })
172                                .is_err()
173                            {
174                                break; // Receiver dropped
175                            }
176                        }
177                        Err(e) => {
178                            log::error!(
179                                "Dynamic profile fetch task panicked for {}: {}",
180                                url_for_log,
181                                e
182                            );
183                        }
184                    }
185                }
186            });
187
188            self.task_handles.push(handle);
189
190            if let Some(status) = self.statuses.get_mut(&source.url) {
191                status.fetching = true;
192            }
193        }
194    }
195
196    /// Stop all background fetch tasks.
197    pub fn stop(&mut self) {
198        for handle in self.task_handles.drain(..) {
199            handle.abort();
200        }
201    }
202
203    /// Trigger an immediate refresh of all enabled sources.
204    pub fn refresh_all(
205        &mut self,
206        sources: &[DynamicProfileSource],
207        runtime: &Arc<tokio::runtime::Runtime>,
208    ) {
209        for source in sources {
210            if !source.enabled || source.url.is_empty() {
211                continue;
212            }
213            self.refresh_source(source, runtime);
214        }
215    }
216
217    /// Trigger an immediate refresh of a specific source.
218    pub fn refresh_source(
219        &mut self,
220        source: &DynamicProfileSource,
221        runtime: &Arc<tokio::runtime::Runtime>,
222    ) {
223        let tx = self.update_tx.clone();
224        let source_clone = source.clone();
225        let url_for_log = source.url.clone();
226        runtime.spawn(async move {
227            let conflict = source_clone.conflict_resolution.clone();
228            match tokio::task::spawn_blocking(move || fetch_profiles(&source_clone)).await {
229                Ok(result) => {
230                    let _ = tx.send(DynamicProfileUpdate {
231                        url: result.url.clone(),
232                        profiles: result.profiles,
233                        conflict_resolution: conflict,
234                        error: result.error,
235                    });
236                }
237                Err(e) => {
238                    log::error!(
239                        "Dynamic profile fetch task panicked for {}: {}",
240                        url_for_log,
241                        e
242                    );
243                }
244            }
245        });
246
247        if let Some(status) = self.statuses.get_mut(&source.url) {
248            status.fetching = true;
249        }
250    }
251
252    /// Check for pending updates (non-blocking).
253    pub fn try_recv(&mut self) -> Option<DynamicProfileUpdate> {
254        self.update_rx.try_recv().ok()
255    }
256
257    /// Update source status after receiving an update.
258    pub fn update_status(&mut self, update: &DynamicProfileUpdate) {
259        if let Some(status) = self.statuses.get_mut(&update.url) {
260            status.fetching = false;
261            status.last_error = update.error.clone();
262            if update.error.is_none() {
263                status.last_fetch = Some(SystemTime::now());
264                status.profile_count = update.profiles.len();
265            }
266        }
267    }
268}
269
270impl Default for DynamicProfileManager {
271    fn default() -> Self {
272        Self::new()
273    }
274}
275
276impl Drop for DynamicProfileManager {
277    fn drop(&mut self) {
278        self.stop();
279    }
280}