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        // Check if latest is newer than current
38        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        // Common header for all update-needed messages
49        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        // Detect the installation method to provide tailored instructions
56        match detect_installation_method(&config.data_dir) {
57            // Handle automatic update via the installer
58            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                // Provide update feedback
80                match status {
81                    Ok(self_update::Status::UpToDate(_)) => unreachable!(),
82                    Ok(self_update::Status::Updated(_)) => {
83                        // If the current version is not present, there has been a gap
84                        let gap = !releases.iter().any(|r| r.version == current_version);
85                        // We don't need considerations for the current version or older
86                        releases.retain(|r| r.version > current_version);
87                        // Build aggregated considerations message
88                        let considerations = build_considerations_message(&releases, &latest_version);
89
90                        // Build the final message
91                        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            // Provide clear, copyable instructions for other installation methods
129            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
138/// Generates user-friendly update instructions based on the installation method
139fn 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
169/// Builds the aggregated considerations message from a list of releases.
170///
171/// `releases` is expected to be ordered Newest -> Oldest (as returned by `get_or_fetch_releases`).
172fn build_considerations_message(releases: &[IntelliShellRelease], latest_version: &Version) -> String {
173    // Collect only releases that have considerations
174    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    // Check if there's a single version with consideration, being the latest
185    let is_single_latest =
186        active_updates.len() == 1 && active_updates.first().map(|(r, _)| &r.version) == Some(latest_version);
187
188    // Reorder to Chronological (Oldest -> Newest) for display
189    active_updates.reverse();
190
191    // Aggregate considerations
192    let mut considerations = String::new();
193    for (release, cons) in active_updates {
194        // Add Version Header if needed
195        if !is_single_latest {
196            considerations.push_str(&format!("- **{}**\n", release.tag));
197        }
198        // Process body lines
199        for line in cons.lines() {
200            let trimmed = line.trim();
201            if trimmed.is_empty() {
202                continue;
203            }
204
205            // Check if the line already acts as a list item
206            let has_bullet = trimmed.starts_with('-') || trimmed.starts_with('*') || trimmed.starts_with('+');
207
208            // Normalize: Ensure the line is a list item
209            // If it has a bullet, we keep the raw 'line' to preserve existing nesting/indentation.
210            // If it doesn't, we force it to be a bullet item.
211            let normalized_line = if has_bullet {
212                Cow::Borrowed(line)
213            } else {
214                Cow::Owned(format!("- {}", trimmed))
215            };
216
217            // Indent: Shift everything right if we are nesting under a version header
218            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
229/// Extracts the "Update Considerations" section from the release body, if present.
230fn 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        // Case 1: Multiple updates with considerations
335        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        // Expected order: 1.0.0, then 1.2.0
348        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        // Case 2: Single latest update with considerations
355        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        // Should NOT have version header
361        assert!(!msg_single.contains("**v1.3.0**"));
362        // Should NOT have indentation
363        assert!(msg_single.contains("- Just me"));
364        assert!(!msg_single.contains("  - Just me"));
365
366        // Case 3: Single OLD update with considerations (e.g. skipped 1.4, installing 1.5 which has none, but 1.4 has)
367        // If active_updates has 1 element which is NOT latest, it should have header.
368        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}