Skip to main content

intelli_shell/process/
update.rs

1use std::borrow::Cow;
2
3use semver::Version;
4use tokio_util::sync::CancellationToken;
5
6use crate::{
7    cli::UpdateProcess,
8    config::{Config, Theme},
9    errors::AppError,
10    format_error,
11    model::IntelliShellRelease,
12    process::{Process, ProcessOutput},
13    service::IntelliShellService,
14    utils::{InstallationMethod, VersionExt, detect_installation_method, render_markdown_to_ansi},
15};
16
17impl Process for UpdateProcess {
18    async fn execute(
19        self,
20        config: Config,
21        service: IntelliShellService,
22        cancellation_token: CancellationToken,
23    ) -> color_eyre::Result<ProcessOutput> {
24        let current_version_str = env!("CARGO_PKG_VERSION");
25        let current_version_tag = format!("v{current_version_str}");
26        let current_version = crate::service::CURRENT_VERSION.clone();
27
28        // Force fetch to ensure we have latest data
29        let mut releases = match service.get_or_fetch_releases(true, cancellation_token).await {
30            Ok(r) => r,
31            Err(AppError::UserFacing(err)) => {
32                return Ok(ProcessOutput::fail().stderr(format_error!(config.theme, "{err}")));
33            }
34            Err(AppError::Unexpected(report)) => return Err(report),
35        };
36
37        let latest_release = releases.first().map(|r| r.version.clone());
38
39        let target_version = if let Some(target) = &self.to {
40            // Find the release matching the target version
41            if !releases.iter().any(|r| &r.version == target) {
42                return Ok(ProcessOutput::fail().stderr(format_error!(
43                    config.theme,
44                    "Requested version {} was not found in the list of releases.",
45                    config.theme.accent.apply(target)
46                )));
47            }
48            target.clone()
49        } else {
50            // Check if latest is newer than current
51            match releases.first() {
52                Some(r) if r.version > current_version => r.version.clone(),
53                _ => {
54                    return Ok(ProcessOutput::success().stdout(format!(
55                        "You're all set! You are running the latest version of intelli-shell ({}).",
56                        config.theme.accent.apply(current_version_tag)
57                    )));
58                }
59            }
60        };
61
62        // Common header for all update-needed messages
63        let header = if let Some(target) = &self.to {
64            if target > &current_version {
65                format!(
66                    "🚀 Updating to version {} (from {})",
67                    config.theme.accent.apply(target),
68                    config.theme.secondary.apply(&current_version_tag)
69                )
70            } else if target < &current_version {
71                format!(
72                    "⏬ Downgrading to version {} (from {})",
73                    config.theme.accent.apply(target),
74                    config.theme.secondary.apply(&current_version_tag)
75                )
76            } else {
77                format!("🔄 Reinstalling version {}", config.theme.accent.apply(target))
78            }
79        } else {
80            format!(
81                "🚀 A new version is available! ({} -> {})",
82                config.theme.secondary.apply(&current_version_tag),
83                config.theme.accent.apply(target_version.to_tag()),
84            )
85        };
86
87        // Detect the installation method to provide tailored instructions
88        match detect_installation_method(&config.data_dir) {
89            // Handle automatic update via the installer
90            InstallationMethod::Installer => {
91                println!("{header}\n\nDownloading ...");
92
93                let target_version_tag = target_version.to_tag();
94                let status = tokio::task::spawn_blocking(move || {
95                    self_update::backends::github::Update::configure()
96                        .repo_owner("lasantosr")
97                        .repo_name("intelli-shell")
98                        .bin_name("intelli-shell")
99                        .show_output(false)
100                        .show_download_progress(true)
101                        .no_confirm(true)
102                        .current_version(current_version_str)
103                        .target_version_tag(&target_version_tag)
104                        .build()?
105                        .update()
106                })
107                .await?;
108
109                println!("\n");
110
111                // Provide update feedback
112                match status {
113                    Ok(self_update::Status::UpToDate(_)) => unreachable!(),
114                    Ok(self_update::Status::Updated(_)) => {
115                        // If the current version is not present, there has been a gap
116                        let gap =
117                            target_version > current_version && !releases.iter().any(|r| r.version == current_version);
118                        // We don't need considerations for the current version or older, or versions beyond
119                        // target_version
120                        releases.retain(|r| r.version > current_version && r.version <= target_version);
121                        // Build aggregated considerations message
122                        let considerations = build_considerations_message(&releases, &target_version);
123
124                        // Build the final message
125                        let mut msg = format!(
126                            "✅ You're all set! You are now on intelli-shell {}.\n\n",
127                            config.theme.accent.apply(target_version.to_tag())
128                        );
129                        if !considerations.is_empty() {
130                            if gap {
131                                msg.push_str(
132                                    "⚠️ You have skipped many versions. The following migration steps are required, \
133                                     but please check GitHub Releases as this list may be incomplete:\n\n",
134                                );
135                            } else {
136                                msg.push_str("💡 Some updates require additional steps to complete:\n\n");
137                            }
138                        } else if gap {
139                            msg.push_str(
140                                "⚠️ You have skipped many versions, please check GitHub Releases to ensure no manual \
141                                 migration steps were missed.\n\n",
142                            );
143                        }
144                        if !considerations.is_empty() {
145                            msg.push_str(&render_markdown_to_ansi(&considerations, &config.theme));
146                            msg.push_str("\n\n");
147                        }
148                        let is_latest = latest_release.as_ref() == Some(&target_version);
149                        let changelog_cmd = if target_version > current_version {
150                            if is_latest {
151                                format!("intelli-shell changelog --from {}", current_version_str)
152                            } else {
153                                format!(
154                                    "intelli-shell changelog --from {} --to {}",
155                                    current_version_str, target_version
156                                )
157                            }
158                        } else if target_version < current_version {
159                            format!(
160                                "intelli-shell changelog --from {} --to {}",
161                                target_version, current_version_str
162                            )
163                        } else {
164                            format!(
165                                "intelli-shell changelog --from {} --to {}",
166                                target_version, target_version
167                            )
168                        };
169
170                        msg.push_str(&format!(
171                            "📄 To view the full changelog, run: {}",
172                            config.theme.accent.apply(changelog_cmd)
173                        ));
174                        Ok(ProcessOutput::success().stdout(msg))
175                    }
176                    Err(err) => Ok(ProcessOutput::fail().stderr(format!(
177                        "❌ Update failed:\n{err}\n\nPlease check your network connection or file permissions.",
178                    ))),
179                }
180            }
181            // Provide clear, copyable instructions for other installation methods
182            installation_method => {
183                let instructions = get_manual_update_instructions(installation_method, &config.theme);
184                let full_message = format!("{header}\n\n{instructions}");
185                Ok(ProcessOutput::success().stdout(full_message))
186            }
187        }
188    }
189}
190
191/// Generates user-friendly update instructions based on the installation method
192fn get_manual_update_instructions(method: InstallationMethod, theme: &Theme) -> String {
193    match method {
194        InstallationMethod::Cargo => format!(
195            "It looks like you installed with {}. To update, please run:\n\n{}\n",
196            theme.secondary.apply("cargo"),
197            theme
198                .accent
199                .apply("  LIBSQLITE3_FLAGS=\"-DSQLITE_ENABLE_MATH_FUNCTIONS\" cargo install intelli-shell --locked")
200        ),
201        InstallationMethod::Nix => format!(
202            "It looks like you installed with {}. Consider updating it via your Nix configuration.",
203            theme.secondary.apply("Nix")
204        ),
205        InstallationMethod::Homebrew => format!(
206            "It looks like you installed with {}. To update, please run:\n\n{}\n",
207            theme.secondary.apply("Homebrew"),
208            theme.accent.apply("  brew upgrade intelli-shell")
209        ),
210        InstallationMethod::Source => format!(
211            "It looks like you installed from {}. You might need to run:\n\n{}\n",
212            theme.secondary.apply("source"),
213            theme.accent.apply("  git pull && cargo build --release")
214        ),
215        InstallationMethod::Unknown(Some(path)) => format!(
216            "Could not determine the installation method. Your executable is located at:\n\n  {}\n\nPlease update \
217             manually or consider reinstalling with the recommended script.",
218            theme.accent.apply(path)
219        ),
220        InstallationMethod::Unknown(None) => {
221            "Could not determine the installation method. Please update manually.".to_string()
222        }
223        InstallationMethod::Installer => unreachable!(),
224    }
225}
226
227/// Builds the aggregated considerations message from a list of releases.
228///
229/// `releases` is expected to be ordered Newest -> Oldest (as returned by `get_or_fetch_releases`).
230fn build_considerations_message(releases: &[IntelliShellRelease], latest_version: &Version) -> String {
231    // Collect only releases that have considerations
232    let mut active_updates: Vec<(&IntelliShellRelease, String)> = releases
233        .iter()
234        .filter_map(|r| {
235            r.body
236                .as_deref()
237                .and_then(extract_update_considerations)
238                .map(|c| (r, c))
239        })
240        .collect();
241
242    // Check if there's a single version with consideration, being the latest
243    let is_single_latest =
244        active_updates.len() == 1 && active_updates.first().map(|(r, _)| &r.version) == Some(latest_version);
245
246    // Reorder to Chronological (Oldest -> Newest) for display
247    active_updates.reverse();
248
249    // Aggregate considerations
250    let mut considerations = String::new();
251    for (release, cons) in active_updates {
252        // Add Version Header if needed
253        if !is_single_latest {
254            considerations.push_str(&format!("- **{}**\n", release.tag));
255        }
256        // Process body lines
257        for line in cons.lines() {
258            let trimmed = line.trim();
259            if trimmed.is_empty() {
260                continue;
261            }
262
263            // Check if the line already acts as a list item
264            let has_bullet = trimmed.starts_with('-') || trimmed.starts_with('*') || trimmed.starts_with('+');
265
266            // Normalize: Ensure the line is a list item
267            // If it has a bullet, we keep the raw 'line' to preserve existing nesting/indentation.
268            // If it doesn't, we force it to be a bullet item.
269            let normalized_line = if has_bullet {
270                Cow::Borrowed(line)
271            } else {
272                Cow::Owned(format!("- {}", trimmed))
273            };
274
275            // Indent: Shift everything right if we are nesting under a version header
276            if !is_single_latest {
277                considerations.push_str("  ");
278            }
279
280            considerations.push_str(&normalized_line);
281            considerations.push('\n');
282        }
283    }
284    considerations
285}
286
287/// Extracts the "Update Considerations" section from the release body, if present.
288fn extract_update_considerations(body: &str) -> Option<String> {
289    let lines = body.lines();
290    let mut capturing = false;
291    let mut content = String::new();
292
293    for line in lines {
294        let trimmed = line.trim();
295        if trimmed.starts_with('#') {
296            if trimmed.to_lowercase().contains("update consideration")
297                || trimmed.to_lowercase().contains("update instructions")
298                || trimmed.to_lowercase().contains("update guide")
299                || trimmed.to_lowercase().contains("upgrade consideration")
300                || trimmed.to_lowercase().contains("upgrade instructions")
301                || trimmed.to_lowercase().contains("upgrade guide")
302                || trimmed.to_lowercase().contains("migration")
303            {
304                capturing = true;
305                continue;
306            } else if capturing {
307                break;
308            }
309        }
310
311        if capturing {
312            if !content.is_empty() {
313                content.push('\n');
314            }
315            content.push_str(line);
316        }
317    }
318
319    let trimmed_content = content.trim();
320    if trimmed_content.is_empty() {
321        None
322    } else {
323        Some(trimmed_content.to_string())
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use chrono::Utc;
330
331    use super::*;
332    use crate::model::IntelliShellRelease;
333
334    #[test]
335    fn test_extract_update_considerations() {
336        let body = r#"
337# Release Notes
338
339Some intro text.
340
341## Update Considerations
342
343This is a critical update.
344Please restart your shell.
345
346## Changelog
347
348- Fix bug A
349- Add feature B
350"#;
351        let expected = "This is a critical update.\nPlease restart your shell.";
352        assert_eq!(extract_update_considerations(body), Some(expected.to_string()));
353
354        let body_no_considerations = r#"
355# Release Notes
356
357## Changelog
358- Fix bug A
359"#;
360        assert_eq!(extract_update_considerations(body_no_considerations), None);
361
362        let body_empty_considerations = r#"
363## Update Considerations
364
365## Changelog
366"#;
367        assert_eq!(extract_update_considerations(body_empty_considerations), None);
368
369        let body_last_section = r#"
370## Update Considerations
371Last section.
372"#;
373        assert_eq!(
374            extract_update_considerations(body_last_section),
375            Some("Last section.".to_string())
376        );
377    }
378
379    #[test]
380    fn test_build_considerations_message() {
381        fn make_release(version: &str, body: Option<&str>) -> IntelliShellRelease {
382            IntelliShellRelease {
383                tag: format!("v{}", version),
384                version: Version::parse(version).unwrap(),
385                title: "Release".into(),
386                body: body.map(|s| s.into()),
387                published_at: Utc::now(),
388                fetched_at: Utc::now(),
389            }
390        }
391
392        // Case 1: Multiple updates with considerations
393        let releases_multi = vec![
394            make_release("1.2.0", Some("## Update Considerations\nCritical 1.2")),
395            make_release("1.1.0", Some("No considerations")),
396            make_release(
397                "1.0.0",
398                Some("## Update Considerations\n- Explicit list item\nImplicit item"),
399            ),
400        ];
401        let latest_multi = Version::parse("1.2.0").unwrap();
402
403        let msg_multi = build_considerations_message(&releases_multi, &latest_multi);
404
405        // Expected order: 1.0.0, then 1.2.0
406        assert!(msg_multi.contains("- **v1.0.0**"));
407        assert!(msg_multi.contains("  - Explicit list item"));
408        assert!(msg_multi.contains("  - Implicit item"));
409        assert!(msg_multi.contains("- **v1.2.0**"));
410        assert!(msg_multi.contains("  - Critical 1.2"));
411
412        // Case 2: Single latest update with considerations
413        let releases_single = vec![make_release("1.3.0", Some("## Update Considerations\nJust me"))];
414        let latest_single = Version::parse("1.3.0").unwrap();
415
416        let msg_single = build_considerations_message(&releases_single, &latest_single);
417
418        // Should NOT have version header
419        assert!(!msg_single.contains("**v1.3.0**"));
420        // Should NOT have indentation
421        assert!(msg_single.contains("- Just me"));
422        assert!(!msg_single.contains("  - Just me"));
423
424        // Case 3: Single OLD update with considerations (e.g. skipped 1.4, installing 1.5 which has none, but 1.4 has)
425        // If active_updates has 1 element which is NOT latest, it should have header.
426        let releases_gap = vec![
427            make_release("1.5.0", None),
428            make_release("1.4.0", Some("## Update Considerations\nGap update")),
429        ];
430        let latest_gap = Version::parse("1.5.0").unwrap();
431
432        let msg_gap = build_considerations_message(&releases_gap, &latest_gap);
433
434        assert!(msg_gap.contains("- **v1.4.0**"));
435        assert!(msg_gap.contains("  - Gap update"));
436    }
437}