diff --git a/.github/ISSUE_TEMPLATE/step_request.md b/.github/ISSUE_TEMPLATE/step_request.md index f7c37bbdf..f10911425 100644 --- a/.github/ISSUE_TEMPLATE/step_request.md +++ b/.github/ISSUE_TEMPLATE/step_request.md @@ -20,7 +20,8 @@ assignees: '' * Which exact commands should Topgrade run? * Does it have a `--dry-run` option? i.e., print what should be done and exit * Does it need the user to confirm the execution? And does it provide a `--yes` - option to skip this? + option to skip this step? +* Can Topgrade extract the components that it updated to use in the summary? ## More information diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ad336a8d0..e33108010 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -28,6 +28,8 @@ If you are an AI agent acting autonomously, please state so. - [ ] *Optional:* The `--dry-run` option works with this step - [ ] *Optional:* The `--yes` option works with this step if it is supported by the underlying command +- [ ] *Optional:* This step extracts and returns its updated components + - [ ] Maintainer: update tracking issue #1918 diff --git a/src/runner.rs b/src/runner.rs index 2270c2328..e9fe7d9d2 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,7 +1,7 @@ use color_eyre::eyre::{Result, WrapErr}; use rust_i18n::t; use std::borrow::Cow; -use std::fmt::Debug; +use std::fmt::{Debug, Display}; use std::io; use tracing::debug; @@ -12,7 +12,7 @@ use crate::step::Step; use crate::terminal::{ShouldRetry, print_error, print_warning, should_retry}; pub enum StepResult { - Success, + Success(Option), Failure, Ignored, SkippedMissingSudo, @@ -24,7 +24,7 @@ impl StepResult { use StepResult::*; match self { - Success | Ignored | Skipped(_) | SkippedMissingSudo => false, + Success(_) | Ignored | Skipped(_) | SkippedMissingSudo => false, Failure => true, } } @@ -38,6 +38,61 @@ enum RetryDecision { type Report<'a> = Vec<(Cow<'a, str>, StepResult)>; +pub struct UpdatedComponents(Vec); + +impl UpdatedComponents { + pub fn new(updated: Vec) -> Self { + Self(updated) + } +} + +impl Display for UpdatedComponents { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0.as_slice() { + [] => write!(f, "No updates found"), + components => { + writeln!(f, "Updated:")?; + let updates = components + .iter() + .map(|c| format!("- {c}")) + .collect::>() + .join("\n"); + write!(f, "{updates}")?; + Ok(()) + } + } + } +} + +pub struct UpdatedComponent { + name: String, + from_version: Option, + to_version: Option, +} + +impl UpdatedComponent { + pub fn new(name: String, from_version: Option, to_version: Option) -> Self { + Self { + name, + from_version, + to_version, + } + } +} + +impl Display for UpdatedComponent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match (&self.from_version, &self.to_version) { + (None, None) => write!(f, "{}", self.name), + (None, Some(to_version)) => write!(f, "{} to {}", self.name, to_version), + (Some(from_version), None) => write!(f, "{} from {}", self.name, from_version), + (Some(from_version), Some(to_version)) => { + write!(f, "{} from {} to {}", self.name, from_version, to_version) + } + } + } +} + pub struct Runner<'a> { ctx: &'a ExecutionContext<'a>, report: Report<'a>, @@ -78,6 +133,22 @@ impl<'a> Runner<'a> { where K: Into> + Debug, F: Fn() -> Result<()>, + { + self._execute(step, key, || func().map(|()| None)) + } + + pub fn execute_with_updated(&mut self, step: Step, key: K, func: F) -> Result<()> + where + K: Into> + Debug, + F: Fn() -> Result>, + { + self._execute(step, key, || func().map(Some)) + } + + fn _execute(&mut self, step: Step, key: K, func: F) -> Result<()> + where + K: Into> + Debug, + F: Fn() -> Result>>, { if !self.ctx.config().should_run(step) { return Ok(()); @@ -101,8 +172,8 @@ impl<'a> Runner<'a> { loop { match func() { - Ok(()) => { - self.push_result(key, StepResult::Success); + Ok(updated) => { + self.push_result(key, StepResult::Success(updated.map(UpdatedComponents::new))); break; } Err(e) if e.downcast_ref::().is_some() => break, diff --git a/src/step.rs b/src/step.rs index e7c06fcb1..fead3edf7 100644 --- a/src/step.rs +++ b/src/step.rs @@ -669,7 +669,7 @@ impl Step { runner.execute(*self, "tpack", || tmux::run_tpack(ctx))? } Typst => runner.execute(*self, "Typst", || generic::run_typst(ctx))?, - Uv => runner.execute(*self, "uv", || generic::run_uv(ctx))?, + Uv => runner.execute_with_updated(*self, "uv", || generic::run_uv(ctx))?, Vagrant => { if ctx.config().should_run(Vagrant) && let Ok(boxes) = vagrant::collect_boxes(ctx) diff --git a/src/steps/generic.rs b/src/steps/generic.rs index 5ed16bc16..8dd2f9e0c 100644 --- a/src/steps/generic.rs +++ b/src/steps/generic.rs @@ -1,8 +1,6 @@ -use color_eyre::eyre::Context; -use color_eyre::eyre::Result; -use color_eyre::eyre::{OptionExt, eyre}; +use color_eyre::eyre::{Context, OptionExt, Result, eyre}; use jetbrains_toolbox_updater::{FindError, find_jetbrains_toolbox, update_jetbrains_toolbox}; -use regex::bytes::Regex; +use regex::Regex; use rust_i18n::t; use semver::Version; use serde::Deserialize; @@ -20,6 +18,7 @@ use crate::command::{CommandExt, Utf8Output}; use crate::execution_context::ExecutionContext; use crate::executor::ExecutorOutput; use crate::output_changed_message; +use crate::runner::UpdatedComponent; use crate::step::Step; use crate::sudo::SudoExecuteOpts; use crate::terminal::{print_separator, shell}; @@ -1494,8 +1493,8 @@ pub fn run_poetry(ctx: &ExecutionContext) -> Result<()> { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; - static SHEBANG_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r"^#![ \t]*([^ \t\n]+)(?:[ \t]+([^\n]+)?)?").unwrap()); + static SHEBANG_REGEX: LazyLock = + LazyLock::new(|| regex::bytes::Regex::new(r"^#![ \t]*([^ \t\n]+)(?:[ \t]+([^\n]+)?)?").unwrap()); let script = fs::read(poetry)?; if let Some(c) = SHEBANG_REGEX.captures(&script) { @@ -1514,8 +1513,9 @@ pub fn run_poetry(ctx: &ExecutionContext) -> Result<()> { use std::str; - static SHEBANG_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r#"^#![ \t]*(?:"([^"\n]+)"|([^" \t\n]+))(?:[ \t]+([^\n]+)?)?"#).unwrap()); + static SHEBANG_REGEX: LazyLock = LazyLock::new(|| { + regex::bytes::Regex::new(r#"^#![ \t]*(?:"([^"\n]+)"|([^" \t\n]+))(?:[ \t]+([^\n]+)?)?"#).unwrap() + }); let data = fs::read(poetry)?; @@ -1589,10 +1589,12 @@ pub fn run_poetry(ctx: &ExecutionContext) -> Result<()> { ctx.execute(&poetry).args(["self", "update"]).status_checked() } -pub fn run_uv(ctx: &ExecutionContext) -> Result<()> { +pub fn run_uv(ctx: &ExecutionContext) -> Result> { let uv_exec = require("uv")?; print_separator("uv"); + let mut updated = vec![]; + // Run `uv self update` if the `uv` binary is built with the `self-update` // cargo feature enabled. // @@ -1630,6 +1632,8 @@ pub fn run_uv(ctx: &ExecutionContext) -> Result<()> { let version = Version::parse(version_str).wrap_err_with(|| output_changed_message!("uv --version", "Invalid version"))?; + let mut self_output = None; + if version < Version::new(0, 4, 25) { // For uv before version 0.4.25 (exclusive), the `self` sub-command only // exists under the `self-update` feature, we run `uv self --help` to check @@ -1642,7 +1646,12 @@ pub fn run_uv(ctx: &ExecutionContext) -> Result<()> { .is_ok(); if self_update_feature_enabled { - ctx.execute(&uv_exec).args(["self", "update"]).status_checked()?; + let output = ctx.execute(&uv_exec).args(["self", "update"]).output_checked()?; + + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + + self_output = Some(output); } } else { // After 0.4.25 (inclusive), running `uv self` succeeds regardless of the @@ -1677,7 +1686,7 @@ pub fn run_uv(ctx: &ExecutionContext) -> Result<()> { ExecutorOutput::Wet(wet) => wet, ExecutorOutput::Dry => return Err(DryRun().into()), }; - let stderr = std::str::from_utf8(&output.stderr).expect("output should be UTF-8 encoded"); + let stderr = std::str::from_utf8(&output.stderr).wrap_err("Output should be valid UTF-8")?; if ERROR_MSGS.iter().any(|&n| stderr.contains(n)) { // Feature `self-update` is disabled, nothing to do. @@ -1691,10 +1700,36 @@ pub fn run_uv(ctx: &ExecutionContext) -> Result<()> { if !output.status.success() { return Err(eyre!("uv self update failed")); } + + self_output = Some(output); } }; + // Extract if the self-update happened + + static UV_SELF_REGEX: LazyLock = LazyLock::new(|| { + Regex::new( + r"success: (?:(?:You're on the latest version of uv \((?:v[\.0-9]+)\))|(?:Upgraded uv from (v[\.0-9]+) to (v[\.0-9]+)!))" + ).expect("Uv self-update output regex always compiles") + }); + + if let Some(output) = self_output { + let captures = UV_SELF_REGEX + .captures(std::str::from_utf8(&output.stderr).wrap_err("Output should be valid UTF-8")?) + .ok_or_else(|| eyre!(output_changed_message!("uv self update", "regex did not match")))?; + match (captures.get(1), captures.get(2)) { + (None, None) => (), + (Some(from_version), Some(to_version)) => updated.push(UpdatedComponent::new( + "(self-update) uv".to_string(), + Some(from_version.as_str().to_string()), + Some(to_version.as_str().to_string()), + )), + _ => unreachable!("Regex should match none or both groups"), + } + } + // Update the installed tools + // TODO: include this in `updated` ctx.execute(&uv_exec) .args(["tool", "upgrade", "--all"]) .status_checked()?; @@ -1709,7 +1744,7 @@ pub fn run_uv(ctx: &ExecutionContext) -> Result<()> { ctx.execute(&uv_exec).args(["cache", "prune"]).status_checked()?; } - Ok(()) + Ok(updated) } /// Involve `zvm upgrade` to update ZVM diff --git a/src/terminal.rs b/src/terminal.rs index a8f7ecdfd..9c9ba601b 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -171,7 +171,15 @@ impl Terminal { "{}: {}\n", key, match result { - StepResult::Success => format!("{}", style(t!("OK")).bold().green()), + StepResult::Success(updated) => { + let mut s = format!("{}", style(t!("OK")).bold().green()); + // Only add the ": No updates found" or ": Updated:" when this step + // supports extracting updated components + if let Some(updated) = updated { + s.push_str(&format!(": {updated}")); + } + s + } StepResult::Failure => format!("{}", style(t!("FAILED")).bold().red()), StepResult::Ignored => format!("{}", style(t!("IGNORED")).bold().yellow()), StepResult::SkippedMissingSudo => format!(