deps_lsp/
progress.rs

1//! LSP Work Done Progress protocol support for loading indicators.
2//!
3//! Implements the `window/workDoneProgress` protocol to show registry fetch
4//! progress in the editor UI.
5//!
6//! # Protocol Flow
7//!
8//! 1. `window/workDoneProgress/create` - Request token creation
9//! 2. `$/progress` with `WorkDoneProgressBegin` - Start indicator
10//! 3. `$/progress` with `WorkDoneProgressReport` - Update progress (optional)
11//! 4. `$/progress` with `WorkDoneProgressEnd` - Complete indicator
12//!
13//! # Drop Behavior
14//!
15//! If dropped without calling `end()`, spawns a cleanup task to send
16//! the end notification. This is best-effort - the task may not complete
17//! if the runtime is shutting down.
18
19use tower_lsp_server::Client;
20use tower_lsp_server::jsonrpc::Result;
21use tower_lsp_server::ls_types::{
22    ProgressParams, ProgressParamsValue, ProgressToken, WorkDoneProgress, WorkDoneProgressBegin,
23    WorkDoneProgressEnd, WorkDoneProgressReport,
24};
25
26/// Progress tracker for registry data fetching.
27///
28/// Manages the lifecycle of an LSP progress indicator, from creation
29/// through updates to completion.
30pub struct RegistryProgress {
31    client: Client,
32    token: ProgressToken,
33    active: bool,
34}
35
36impl RegistryProgress {
37    /// Create and start a new progress indicator.
38    ///
39    /// # Arguments
40    ///
41    /// * `client` - LSP client for sending notifications
42    /// * `uri` - Document URI (used to create unique token)
43    /// * `total_deps` - Total number of dependencies to fetch
44    ///
45    /// # Returns
46    ///
47    /// Returns `Ok(RegistryProgress)` if progress is supported by the client,
48    /// or `Err` if the client doesn't support progress notifications.
49    ///
50    /// # Errors
51    ///
52    /// Returns error if:
53    /// - Client doesn't support progress (no workDoneProgress capability)
54    /// - Failed to create progress token
55    pub async fn start(client: Client, uri: &str, total_deps: usize) -> Result<Self> {
56        let token = ProgressToken::String(format!("deps-fetch-{}", uri));
57
58        // Request progress token creation
59        client
60            .send_request::<tower_lsp_server::ls_types::request::WorkDoneProgressCreate>(
61                tower_lsp_server::ls_types::WorkDoneProgressCreateParams {
62                    token: token.clone(),
63                },
64            )
65            .await?;
66
67        // Send begin notification
68        client
69            .send_notification::<tower_lsp_server::ls_types::notification::Progress>(
70                ProgressParams {
71                    token: token.clone(),
72                    value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
73                        WorkDoneProgressBegin {
74                            title: "Fetching package versions".to_string(),
75                            message: Some(format!("Loading {} dependencies...", total_deps)),
76                            cancellable: Some(false),
77                            percentage: Some(0),
78                        },
79                    )),
80                },
81            )
82            .await;
83
84        Ok(Self {
85            client,
86            token,
87            active: true,
88        })
89    }
90
91    /// Update progress (optional, for partial updates).
92    ///
93    /// # Arguments
94    ///
95    /// * `fetched` - Number of packages fetched so far
96    /// * `total` - Total number of packages
97    ///
98    /// # Note
99    ///
100    /// This method should be called sparingly (e.g., every 20% progress)
101    /// to avoid flooding the client with notifications.
102    pub async fn update(&self, fetched: usize, total: usize) {
103        if !self.active || total == 0 {
104            return;
105        }
106
107        let percentage = ((fetched as f64 / total as f64) * 100.0) as u32;
108        self.client
109            .send_notification::<tower_lsp_server::ls_types::notification::Progress>(
110                ProgressParams {
111                    token: self.token.clone(),
112                    value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report(
113                        WorkDoneProgressReport {
114                            message: Some(format!("Fetched {}/{} packages", fetched, total)),
115                            percentage: Some(percentage),
116                            cancellable: Some(false),
117                        },
118                    )),
119                },
120            )
121            .await;
122    }
123
124    /// End progress indicator.
125    ///
126    /// # Arguments
127    ///
128    /// * `success` - Whether the fetch completed successfully
129    pub async fn end(mut self, success: bool) {
130        if !self.active {
131            return;
132        }
133
134        self.active = false;
135        let message = if success {
136            "Package versions loaded"
137        } else {
138            "Failed to fetch some versions"
139        };
140
141        self.client
142            .send_notification::<tower_lsp_server::ls_types::notification::Progress>(
143                ProgressParams {
144                    token: self.token.clone(),
145                    value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
146                        WorkDoneProgressEnd {
147                            message: Some(message.to_string()),
148                        },
149                    )),
150                },
151            )
152            .await;
153    }
154}
155
156/// Ensure progress is cleaned up on drop
157impl Drop for RegistryProgress {
158    fn drop(&mut self) {
159        if self.active {
160            tracing::warn!(
161                token = ?self.token,
162                "RegistryProgress dropped without explicit end() - spawning cleanup"
163            );
164            // Can't await in Drop, so spawn cleanup task
165            let client = self.client.clone();
166            let token = self.token.clone();
167            tokio::spawn(async move {
168                client
169                    .send_notification::<tower_lsp_server::ls_types::notification::Progress>(
170                        ProgressParams {
171                            token,
172                            value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
173                                WorkDoneProgressEnd { message: None },
174                            )),
175                        },
176                    )
177                    .await;
178            });
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    #[test]
186    fn test_progress_token_format() {
187        let uri = "file:///test/Cargo.toml";
188        let token = format!("deps-fetch-{}", uri);
189        assert_eq!(token, "deps-fetch-file:///test/Cargo.toml");
190    }
191
192    #[test]
193    fn test_percentage_calculation() {
194        let calculate = |fetched: usize, total: usize| -> u32 {
195            if total == 0 {
196                return 0;
197            }
198            ((fetched as f64 / total as f64) * 100.0) as u32
199        };
200
201        assert_eq!(calculate(0, 10), 0);
202        assert_eq!(calculate(5, 10), 50);
203        assert_eq!(calculate(10, 10), 100);
204        assert_eq!(calculate(7, 10), 70);
205        assert_eq!(calculate(0, 0), 0);
206    }
207
208    #[test]
209    fn test_progress_message_format() {
210        let format_message = |fetched: usize, total: usize| -> String {
211            format!("Fetched {}/{} packages", fetched, total)
212        };
213
214        assert_eq!(format_message(5, 10), "Fetched 5/10 packages");
215        assert_eq!(format_message(0, 15), "Fetched 0/15 packages");
216        assert_eq!(format_message(20, 20), "Fetched 20/20 packages");
217    }
218
219    #[test]
220    fn test_update_after_end_is_safe() {
221        // Verify the guard checks prevent operations after end()
222        let active = false;
223        let total = 10;
224
225        // This is the guard in update()
226        if !active || total == 0 {
227            return; // No-op - expected behavior
228        }
229
230        panic!("Should have returned early");
231    }
232
233    #[test]
234    fn test_update_with_zero_total_returns_early() {
235        let active = true;
236        let total = 0;
237
238        if !active || total == 0 {
239            return; // Expected behavior
240        }
241
242        panic!("Should have returned early");
243    }
244
245    #[test]
246    fn test_end_idempotency_flag() {
247        // Verify the active flag behavior
248        let mut active = true;
249
250        // First end() call
251        assert!(active, "First call should proceed");
252        active = false;
253
254        // Second end() call - should be no-op
255        assert!(!active, "Second call should be no-op due to inactive flag");
256    }
257
258    #[test]
259    fn test_drop_cleanup_active_flag_logic() {
260        // Test the logic that determines if cleanup is needed
261        let active = true;
262        let should_cleanup = active;
263        assert!(
264            should_cleanup,
265            "Active progress should trigger cleanup on drop"
266        );
267
268        let active = false;
269        let should_cleanup = active;
270        assert!(
271            !should_cleanup,
272            "Inactive progress should not trigger cleanup"
273        );
274    }
275}