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_version = match releases.first() {
39 Some(r) if r.version > current_version => r.version.clone(),
40 _ => {
41 return Ok(ProcessOutput::success().stdout(format!(
42 "You're all set! You are running the latest version of intelli-shell ({}).",
43 config.theme.accent.apply(current_version_tag)
44 )));
45 }
46 };
47
48 let header = format!(
50 "🚀 A new version is available! ({} -> {})",
51 config.theme.secondary.apply(current_version_tag),
52 config.theme.accent.apply(latest_version.to_tag()),
53 );
54
55 match detect_installation_method(&config.data_dir) {
57 InstallationMethod::Installer => {
59 println!("{header}\n\nDownloading ...");
60
61 let target_version_tag = latest_version.to_tag();
62 let status = tokio::task::spawn_blocking(move || {
63 self_update::backends::github::Update::configure()
64 .repo_owner("lasantosr")
65 .repo_name("intelli-shell")
66 .bin_name("intelli-shell")
67 .show_output(false)
68 .show_download_progress(true)
69 .no_confirm(true)
70 .current_version(current_version_str)
71 .target_version_tag(&target_version_tag)
72 .build()?
73 .update()
74 })
75 .await?;
76
77 println!("\n");
78
79 match status {
81 Ok(self_update::Status::UpToDate(_)) => unreachable!(),
82 Ok(self_update::Status::Updated(_)) => {
83 let gap = !releases.iter().any(|r| r.version == current_version);
85 releases.retain(|r| r.version > current_version);
87 let considerations = build_considerations_message(&releases, &latest_version);
89
90 let mut msg = format!(
92 "✅ You're all set! You are now on intelli-shell {}.\n\n",
93 config.theme.accent.apply(latest_version.to_tag())
94 );
95 if !considerations.is_empty() {
96 if gap {
97 msg.push_str(
98 "⚠️ You have skipped many versions. The following migration steps are required, \
99 but please check GitHub Releases as this list may be incomplete:\n\n",
100 );
101 } else {
102 msg.push_str("💡 Some updates require additional steps to complete:\n\n");
103 }
104 } else if gap {
105 msg.push_str(
106 "⚠️ You have skipped many versions, please check GitHub Releases to ensure no manual \
107 migration steps were missed.\n\n",
108 );
109 }
110 if !considerations.is_empty() {
111 msg.push_str(&render_markdown_to_ansi(&considerations, &config.theme));
112 msg.push_str("\n\n");
113 }
114 msg.push_str(&format!(
115 "📄 To view the full changelog, run: {}",
116 config
117 .theme
118 .accent
119 .apply(format!("intelli-shell changelog --from {}", current_version_str))
120 ));
121 Ok(ProcessOutput::success().stdout(msg))
122 }
123 Err(err) => Ok(ProcessOutput::fail().stderr(format!(
124 "❌ Update failed:\n{err}\n\nPlease check your network connection or file permissions.",
125 ))),
126 }
127 }
128 installation_method => {
130 let instructions = get_manual_update_instructions(installation_method, &config.theme);
131 let full_message = format!("{header}\n\n{instructions}");
132 Ok(ProcessOutput::success().stdout(full_message))
133 }
134 }
135 }
136}
137
138fn get_manual_update_instructions(method: InstallationMethod, theme: &Theme) -> String {
140 match method {
141 InstallationMethod::Cargo => format!(
142 "It looks like you installed with {}. To update, please run:\n\n{}\n",
143 theme.secondary.apply("cargo"),
144 theme
145 .accent
146 .apply(" LIBSQLITE3_FLAGS=\"-DSQLITE_ENABLE_MATH_FUNCTIONS\" cargo install intelli-shell --locked")
147 ),
148 InstallationMethod::Nix => format!(
149 "It looks like you installed with {}. Consider updating it via your Nix configuration.",
150 theme.secondary.apply("Nix")
151 ),
152 InstallationMethod::Source => format!(
153 "It looks like you installed from {}. You might need to run:\n\n{}\n",
154 theme.secondary.apply("source"),
155 theme.accent.apply(" git pull && cargo build --release")
156 ),
157 InstallationMethod::Unknown(Some(path)) => format!(
158 "Could not determine the installation method. Your executable is located at:\n\n {}\n\nPlease update \
159 manually or consider reinstalling with the recommended script.",
160 theme.accent.apply(path)
161 ),
162 InstallationMethod::Unknown(None) => {
163 "Could not determine the installation method. Please update manually.".to_string()
164 }
165 InstallationMethod::Installer => unreachable!(),
166 }
167}
168
169fn build_considerations_message(releases: &[IntelliShellRelease], latest_version: &Version) -> String {
173 let mut active_updates: Vec<(&IntelliShellRelease, String)> = releases
175 .iter()
176 .filter_map(|r| {
177 r.body
178 .as_deref()
179 .and_then(extract_update_considerations)
180 .map(|c| (r, c))
181 })
182 .collect();
183
184 let is_single_latest =
186 active_updates.len() == 1 && active_updates.first().map(|(r, _)| &r.version) == Some(latest_version);
187
188 active_updates.reverse();
190
191 let mut considerations = String::new();
193 for (release, cons) in active_updates {
194 if !is_single_latest {
196 considerations.push_str(&format!("- **{}**\n", release.tag));
197 }
198 for line in cons.lines() {
200 let trimmed = line.trim();
201 if trimmed.is_empty() {
202 continue;
203 }
204
205 let has_bullet = trimmed.starts_with('-') || trimmed.starts_with('*') || trimmed.starts_with('+');
207
208 let normalized_line = if has_bullet {
212 Cow::Borrowed(line)
213 } else {
214 Cow::Owned(format!("- {}", trimmed))
215 };
216
217 if !is_single_latest {
219 considerations.push_str(" ");
220 }
221
222 considerations.push_str(&normalized_line);
223 considerations.push('\n');
224 }
225 }
226 considerations
227}
228
229fn extract_update_considerations(body: &str) -> Option<String> {
231 let lines = body.lines();
232 let mut capturing = false;
233 let mut content = String::new();
234
235 for line in lines {
236 let trimmed = line.trim();
237 if trimmed.starts_with('#') {
238 if trimmed.to_lowercase().contains("update consideration")
239 || trimmed.to_lowercase().contains("update instructions")
240 || trimmed.to_lowercase().contains("update guide")
241 || trimmed.to_lowercase().contains("upgrade consideration")
242 || trimmed.to_lowercase().contains("upgrade instructions")
243 || trimmed.to_lowercase().contains("upgrade guide")
244 || trimmed.to_lowercase().contains("migration")
245 {
246 capturing = true;
247 continue;
248 } else if capturing {
249 break;
250 }
251 }
252
253 if capturing {
254 if !content.is_empty() {
255 content.push('\n');
256 }
257 content.push_str(line);
258 }
259 }
260
261 let trimmed_content = content.trim();
262 if trimmed_content.is_empty() {
263 None
264 } else {
265 Some(trimmed_content.to_string())
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use chrono::Utc;
272
273 use super::*;
274 use crate::model::IntelliShellRelease;
275
276 #[test]
277 fn test_extract_update_considerations() {
278 let body = r#"
279# Release Notes
280
281Some intro text.
282
283## Update Considerations
284
285This is a critical update.
286Please restart your shell.
287
288## Changelog
289
290- Fix bug A
291- Add feature B
292"#;
293 let expected = "This is a critical update.\nPlease restart your shell.";
294 assert_eq!(extract_update_considerations(body), Some(expected.to_string()));
295
296 let body_no_considerations = r#"
297# Release Notes
298
299## Changelog
300- Fix bug A
301"#;
302 assert_eq!(extract_update_considerations(body_no_considerations), None);
303
304 let body_empty_considerations = r#"
305## Update Considerations
306
307## Changelog
308"#;
309 assert_eq!(extract_update_considerations(body_empty_considerations), None);
310
311 let body_last_section = r#"
312## Update Considerations
313Last section.
314"#;
315 assert_eq!(
316 extract_update_considerations(body_last_section),
317 Some("Last section.".to_string())
318 );
319 }
320
321 #[test]
322 fn test_build_considerations_message() {
323 fn make_release(version: &str, body: Option<&str>) -> IntelliShellRelease {
324 IntelliShellRelease {
325 tag: format!("v{}", version),
326 version: Version::parse(version).unwrap(),
327 title: "Release".into(),
328 body: body.map(|s| s.into()),
329 published_at: Utc::now(),
330 fetched_at: Utc::now(),
331 }
332 }
333
334 let releases_multi = vec![
336 make_release("1.2.0", Some("## Update Considerations\nCritical 1.2")),
337 make_release("1.1.0", Some("No considerations")),
338 make_release(
339 "1.0.0",
340 Some("## Update Considerations\n- Explicit list item\nImplicit item"),
341 ),
342 ];
343 let latest_multi = Version::parse("1.2.0").unwrap();
344
345 let msg_multi = build_considerations_message(&releases_multi, &latest_multi);
346
347 assert!(msg_multi.contains("- **v1.0.0**"));
349 assert!(msg_multi.contains(" - Explicit list item"));
350 assert!(msg_multi.contains(" - Implicit item"));
351 assert!(msg_multi.contains("- **v1.2.0**"));
352 assert!(msg_multi.contains(" - Critical 1.2"));
353
354 let releases_single = vec![make_release("1.3.0", Some("## Update Considerations\nJust me"))];
356 let latest_single = Version::parse("1.3.0").unwrap();
357
358 let msg_single = build_considerations_message(&releases_single, &latest_single);
359
360 assert!(!msg_single.contains("**v1.3.0**"));
362 assert!(msg_single.contains("- Just me"));
364 assert!(!msg_single.contains(" - Just me"));
365
366 let releases_gap = vec![
369 make_release("1.5.0", None),
370 make_release("1.4.0", Some("## Update Considerations\nGap update")),
371 ];
372 let latest_gap = Version::parse("1.5.0").unwrap();
373
374 let msg_gap = build_considerations_message(&releases_gap, &latest_gap);
375
376 assert!(msg_gap.contains("- **v1.4.0**"));
377 assert!(msg_gap.contains(" - Gap update"));
378 }
379}