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 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 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 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 let header = if let Some(target) = &self.to {
65 if target > ¤t_version {
66 format!(
67 "🚀 Updating to version {} (from {})",
68 config.theme.accent.apply(target),
69 config.theme.secondary.apply(¤t_version_tag)
70 )
71 } else if target < ¤t_version {
72 format!(
73 "⏬ Downgrading to version {} (from {})",
74 config.theme.accent.apply(target),
75 config.theme.secondary.apply(¤t_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(¤t_version_tag),
84 config.theme.accent.apply(target_version.to_tag()),
85 )
86 };
87
88 match detect_installation_method(&config.data_dir) {
90 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 match status {
114 Ok(self_update::VersionStatus::UpToDate(_)) => unreachable!(),
115 Ok(self_update::VersionStatus::Updated(_)) => {
116 let gap =
118 target_version > current_version && !releases.iter().any(|r| r.version == current_version);
119 releases.retain(|r| r.version > current_version && r.version <= target_version);
122 let considerations = build_considerations_message(&releases, &target_version);
124
125 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 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
193fn 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
229fn build_considerations_message(releases: &[IntelliShellRelease], latest_version: &Version) -> String {
233 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 let is_single_latest =
246 active_updates.len() == 1 && active_updates.first().map(|(r, _)| &r.version) == Some(latest_version);
247
248 active_updates.reverse();
250
251 let mut considerations = String::new();
253 for (release, cons) in active_updates {
254 if !is_single_latest {
256 considerations.push_str(&format!("- **{}**\n", release.tag));
257 }
258 for line in cons.lines() {
260 let trimmed = line.trim();
261 if trimmed.is_empty() {
262 continue;
263 }
264
265 let has_bullet = trimmed.starts_with('-') || trimmed.starts_with('*') || trimmed.starts_with('+');
267
268 let normalized_line = if has_bullet {
272 Cow::Borrowed(line)
273 } else {
274 Cow::Owned(format!("- {}", trimmed))
275 };
276
277 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
289fn 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 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 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 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 assert!(!msg_single.contains("**v1.3.0**"));
422 assert!(msg_single.contains("- Just me"));
424 assert!(!msg_single.contains(" - Just me"));
425
426 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}