use std::borrow::Cow;
use semver::Version;
use tokio_util::sync::CancellationToken;
use crate::{
cli::UpdateProcess,
config::{Config, Theme},
errors::AppError,
format_error,
model::IntelliShellRelease,
process::{Process, ProcessOutput},
service::IntelliShellService,
utils::{InstallationMethod, VersionExt, detect_installation_method, render_markdown_to_ansi},
};
impl Process for UpdateProcess {
async fn execute(
self,
config: Config,
service: IntelliShellService,
cancellation_token: CancellationToken,
) -> color_eyre::Result<ProcessOutput> {
let current_version_str = env!("CARGO_PKG_VERSION");
let current_version_tag = format!("v{current_version_str}");
let current_version = crate::service::CURRENT_VERSION.clone();
let mut releases = match service.get_or_fetch_releases(true, cancellation_token).await {
Ok(r) => r,
Err(AppError::UserFacing(err)) => {
return Ok(ProcessOutput::fail().stderr(format_error!(config.theme, "{err}")));
}
Err(AppError::Unexpected(report)) => return Err(report),
};
let latest_version = match releases.first() {
Some(r) if r.version > current_version => r.version.clone(),
_ => {
return Ok(ProcessOutput::success().stdout(format!(
"You're all set! You are running the latest version of intelli-shell ({}).",
config.theme.accent.apply(current_version_tag)
)));
}
};
let header = format!(
"🚀 A new version is available! ({} -> {})",
config.theme.secondary.apply(current_version_tag),
config.theme.accent.apply(latest_version.to_tag()),
);
match detect_installation_method(&config.data_dir) {
InstallationMethod::Installer => {
println!("{header}\n\nDownloading ...");
let target_version_tag = latest_version.to_tag();
let status = tokio::task::spawn_blocking(move || {
self_update::backends::github::Update::configure()
.repo_owner("lasantosr")
.repo_name("intelli-shell")
.bin_name("intelli-shell")
.show_output(false)
.show_download_progress(true)
.no_confirm(true)
.current_version(current_version_str)
.target_version_tag(&target_version_tag)
.build()?
.update()
})
.await?;
println!("\n");
match status {
Ok(self_update::Status::UpToDate(_)) => unreachable!(),
Ok(self_update::Status::Updated(_)) => {
let gap = !releases.iter().any(|r| r.version == current_version);
releases.retain(|r| r.version > current_version);
let considerations = build_considerations_message(&releases, &latest_version);
let mut msg = format!(
"✅ You're all set! You are now on intelli-shell {}.\n\n",
config.theme.accent.apply(latest_version.to_tag())
);
if !considerations.is_empty() {
if gap {
msg.push_str(
"⚠️ You have skipped many versions. The following migration steps are required, \
but please check GitHub Releases as this list may be incomplete:\n\n",
);
} else {
msg.push_str("💡 Some updates require additional steps to complete:\n\n");
}
} else if gap {
msg.push_str(
"⚠️ You have skipped many versions, please check GitHub Releases to ensure no manual \
migration steps were missed.\n\n",
);
}
if !considerations.is_empty() {
msg.push_str(&render_markdown_to_ansi(&considerations, &config.theme));
msg.push_str("\n\n");
}
msg.push_str(&format!(
"📄 To view the full changelog, run: {}",
config
.theme
.accent
.apply(format!("intelli-shell changelog --from {}", current_version_str))
));
Ok(ProcessOutput::success().stdout(msg))
}
Err(err) => Ok(ProcessOutput::fail().stderr(format!(
"❌ Update failed:\n{err}\n\nPlease check your network connection or file permissions.",
))),
}
}
installation_method => {
let instructions = get_manual_update_instructions(installation_method, &config.theme);
let full_message = format!("{header}\n\n{instructions}");
Ok(ProcessOutput::success().stdout(full_message))
}
}
}
}
fn get_manual_update_instructions(method: InstallationMethod, theme: &Theme) -> String {
match method {
InstallationMethod::Cargo => format!(
"It looks like you installed with {}. To update, please run:\n\n{}\n",
theme.secondary.apply("cargo"),
theme
.accent
.apply(" LIBSQLITE3_FLAGS=\"-DSQLITE_ENABLE_MATH_FUNCTIONS\" cargo install intelli-shell --locked")
),
InstallationMethod::Nix => format!(
"It looks like you installed with {}. Consider updating it via your Nix configuration.",
theme.secondary.apply("Nix")
),
InstallationMethod::Source => format!(
"It looks like you installed from {}. You might need to run:\n\n{}\n",
theme.secondary.apply("source"),
theme.accent.apply(" git pull && cargo build --release")
),
InstallationMethod::Unknown(Some(path)) => format!(
"Could not determine the installation method. Your executable is located at:\n\n {}\n\nPlease update \
manually or consider reinstalling with the recommended script.",
theme.accent.apply(path)
),
InstallationMethod::Unknown(None) => {
"Could not determine the installation method. Please update manually.".to_string()
}
InstallationMethod::Installer => unreachable!(),
}
}
fn build_considerations_message(releases: &[IntelliShellRelease], latest_version: &Version) -> String {
let mut active_updates: Vec<(&IntelliShellRelease, String)> = releases
.iter()
.filter_map(|r| {
r.body
.as_deref()
.and_then(extract_update_considerations)
.map(|c| (r, c))
})
.collect();
let is_single_latest =
active_updates.len() == 1 && active_updates.first().map(|(r, _)| &r.version) == Some(latest_version);
active_updates.reverse();
let mut considerations = String::new();
for (release, cons) in active_updates {
if !is_single_latest {
considerations.push_str(&format!("- **{}**\n", release.tag));
}
for line in cons.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let has_bullet = trimmed.starts_with('-') || trimmed.starts_with('*') || trimmed.starts_with('+');
let normalized_line = if has_bullet {
Cow::Borrowed(line)
} else {
Cow::Owned(format!("- {}", trimmed))
};
if !is_single_latest {
considerations.push_str(" ");
}
considerations.push_str(&normalized_line);
considerations.push('\n');
}
}
considerations
}
fn extract_update_considerations(body: &str) -> Option<String> {
let lines = body.lines();
let mut capturing = false;
let mut content = String::new();
for line in lines {
let trimmed = line.trim();
if trimmed.starts_with('#') {
if trimmed.to_lowercase().contains("update consideration")
|| trimmed.to_lowercase().contains("update instructions")
|| trimmed.to_lowercase().contains("update guide")
|| trimmed.to_lowercase().contains("upgrade consideration")
|| trimmed.to_lowercase().contains("upgrade instructions")
|| trimmed.to_lowercase().contains("upgrade guide")
|| trimmed.to_lowercase().contains("migration")
{
capturing = true;
continue;
} else if capturing {
break;
}
}
if capturing {
if !content.is_empty() {
content.push('\n');
}
content.push_str(line);
}
}
let trimmed_content = content.trim();
if trimmed_content.is_empty() {
None
} else {
Some(trimmed_content.to_string())
}
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use super::*;
use crate::model::IntelliShellRelease;
#[test]
fn test_extract_update_considerations() {
let body = r#"
# Release Notes
Some intro text.
## Update Considerations
This is a critical update.
Please restart your shell.
## Changelog
- Fix bug A
- Add feature B
"#;
let expected = "This is a critical update.\nPlease restart your shell.";
assert_eq!(extract_update_considerations(body), Some(expected.to_string()));
let body_no_considerations = r#"
# Release Notes
## Changelog
- Fix bug A
"#;
assert_eq!(extract_update_considerations(body_no_considerations), None);
let body_empty_considerations = r#"
## Update Considerations
## Changelog
"#;
assert_eq!(extract_update_considerations(body_empty_considerations), None);
let body_last_section = r#"
## Update Considerations
Last section.
"#;
assert_eq!(
extract_update_considerations(body_last_section),
Some("Last section.".to_string())
);
}
#[test]
fn test_build_considerations_message() {
fn make_release(version: &str, body: Option<&str>) -> IntelliShellRelease {
IntelliShellRelease {
tag: format!("v{}", version),
version: Version::parse(version).unwrap(),
title: "Release".into(),
body: body.map(|s| s.into()),
published_at: Utc::now(),
fetched_at: Utc::now(),
}
}
let releases_multi = vec![
make_release("1.2.0", Some("## Update Considerations\nCritical 1.2")),
make_release("1.1.0", Some("No considerations")),
make_release(
"1.0.0",
Some("## Update Considerations\n- Explicit list item\nImplicit item"),
),
];
let latest_multi = Version::parse("1.2.0").unwrap();
let msg_multi = build_considerations_message(&releases_multi, &latest_multi);
assert!(msg_multi.contains("- **v1.0.0**"));
assert!(msg_multi.contains(" - Explicit list item"));
assert!(msg_multi.contains(" - Implicit item"));
assert!(msg_multi.contains("- **v1.2.0**"));
assert!(msg_multi.contains(" - Critical 1.2"));
let releases_single = vec![make_release("1.3.0", Some("## Update Considerations\nJust me"))];
let latest_single = Version::parse("1.3.0").unwrap();
let msg_single = build_considerations_message(&releases_single, &latest_single);
assert!(!msg_single.contains("**v1.3.0**"));
assert!(msg_single.contains("- Just me"));
assert!(!msg_single.contains(" - Just me"));
let releases_gap = vec![
make_release("1.5.0", None),
make_release("1.4.0", Some("## Update Considerations\nGap update")),
];
let latest_gap = Version::parse("1.5.0").unwrap();
let msg_gap = build_considerations_message(&releases_gap, &latest_gap);
assert!(msg_gap.contains("- **v1.4.0**"));
assert!(msg_gap.contains(" - Gap update"));
}
}