Skip to main content

intelli_shell/process/
update.rs

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