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 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 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 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 let header = if let Some(target) = &self.to {
64 if target > ¤t_version {
65 format!(
66 "🚀 Updating to version {} (from {})",
67 config.theme.accent.apply(target),
68 config.theme.secondary.apply(¤t_version_tag)
69 )
70 } else if target < ¤t_version {
71 format!(
72 "⏬ Downgrading to version {} (from {})",
73 config.theme.accent.apply(target),
74 config.theme.secondary.apply(¤t_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(¤t_version_tag),
83 config.theme.accent.apply(target_version.to_tag()),
84 )
85 };
86
87 match detect_installation_method(&config.data_dir) {
89 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 match status {
113 Ok(self_update::Status::UpToDate(_)) => unreachable!(),
114 Ok(self_update::Status::Updated(_)) => {
115 let gap =
117 target_version > current_version && !releases.iter().any(|r| r.version == current_version);
118 releases.retain(|r| r.version > current_version && r.version <= target_version);
121 let considerations = build_considerations_message(&releases, &target_version);
123
124 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 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
191fn 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
227fn build_considerations_message(releases: &[IntelliShellRelease], latest_version: &Version) -> String {
231 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 let is_single_latest =
244 active_updates.len() == 1 && active_updates.first().map(|(r, _)| &r.version) == Some(latest_version);
245
246 active_updates.reverse();
248
249 let mut considerations = String::new();
251 for (release, cons) in active_updates {
252 if !is_single_latest {
254 considerations.push_str(&format!("- **{}**\n", release.tag));
255 }
256 for line in cons.lines() {
258 let trimmed = line.trim();
259 if trimmed.is_empty() {
260 continue;
261 }
262
263 let has_bullet = trimmed.starts_with('-') || trimmed.starts_with('*') || trimmed.starts_with('+');
265
266 let normalized_line = if has_bullet {
270 Cow::Borrowed(line)
271 } else {
272 Cow::Owned(format!("- {}", trimmed))
273 };
274
275 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
287fn 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 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 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 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 assert!(!msg_single.contains("**v1.3.0**"));
420 assert!(msg_single.contains("- Just me"));
422 assert!(!msg_single.contains(" - Just me"));
423
424 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}