Files
Kigi-CLI/crates/codegen/kigi-update/src/minimum_version.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

373 lines
13 KiB
Rust

//! Minimum-version enforcement.
//!
//! When `cli.minimum_version` is set in any config layer, Kigi refuses to
//! start below that floor. With auto-update on, we install
//! `max(latest, minimum)`; otherwise the user is asked to run `kigi update`.
//!
//! Set `KIGI_TEST_VERSION` to manually exercise either path without producing
//! a real out-of-date build.
use crate::auto_update::{get_installer, run_install_script};
use crate::version::{
UpdateConfig, fetch_latest_version, get_installed_kigi_version, write_version_cache,
};
use kigi_shell::util::config;
use tracing::{info, warn};
#[derive(Debug, Clone, PartialEq, Eq)]
enum MinimumVersionDecision {
Allow,
BelowMinimum { current: String, minimum: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum EnforcementOutcome {
Allowed,
/// New binary on disk; caller MUST restart — running process is still old.
Upgraded,
}
/// User-facing enforcement failures; `Display` is printed to stderr.
/// `AutoUpdateDisabled` and `NoInstaller` share copy but stay separate so
/// telemetry can distinguish them.
#[derive(Debug, thiserror::Error)]
pub(crate) enum MinimumVersionError {
#[error(
"The minimum version \"{value}\" in your Kigi configuration \
isn't a valid version number. Update `cli.minimum_version` and try again."
)]
InvalidMinimum {
value: String,
#[source]
source: semver::Error,
},
#[error(
"This version of Kigi ({current}) is no longer supported. \
Run `kigi update` to install version {minimum} or later."
)]
AutoUpdateDisabled { current: String, minimum: String },
#[error(
"This version of Kigi ({current}) is no longer supported. \
Run `kigi update` to install version {minimum} or later."
)]
NoInstaller { current: String, minimum: String },
/// `detail` is telemetry-only; omitted from `Display` to avoid stacking
/// the installer's own action language.
#[error(
"This version of Kigi ({current}) is no longer supported, \
and the update to version {minimum} didn't complete.\n\n\
Run `kigi update` to try again."
)]
UpgradeFailed {
current: String,
minimum: String,
detail: String,
},
/// Latest release is known but still below the floor (vs `NoReleaseFound`,
/// which couldn't probe at all).
#[error(
"This version of Kigi ({current}) is no longer supported. \
Version {minimum} or later is required, but the most recent release is {latest}. \
Contact your administrator."
)]
NoSatisfyingVersion {
current: String,
minimum: String,
latest: String,
},
/// Couldn't probe the registry — likely transient.
#[error(
"This version of Kigi ({current}) is no longer supported. \
Version {minimum} or later is required, but no release was found. \
Check your network connection, or contact your administrator."
)]
NoReleaseFound { current: String, minimum: String },
/// `kigi update --version X` requested a version below the floor.
#[error(
"Cannot install Kigi {target}: the configured minimum is {minimum}. \
Run `kigi update` to install the latest allowed version."
)]
TargetBelowFloor { target: String, minimum: String },
}
/// Pure check against the configured floor. Empty / whitespace-only
/// minimums are treated as unset.
fn evaluate_minimum_version(
current_version: &str,
minimum_version: Option<&str>,
) -> Result<MinimumVersionDecision, MinimumVersionError> {
let Some(minimum) = minimum_version.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(MinimumVersionDecision::Allow);
};
let parsed_min =
semver::Version::parse(minimum).map_err(|source| MinimumVersionError::InvalidMinimum {
value: minimum.to_string(),
source,
})?;
// Unparseable current (e.g. funky dev build): block rather than let an
// unverifiable binary through.
let parsed_cur = match semver::Version::parse(current_version) {
Ok(v) => v,
Err(_) => {
return Ok(MinimumVersionDecision::BelowMinimum {
current: current_version.to_string(),
minimum: parsed_min.to_string(),
});
}
};
if parsed_cur >= parsed_min {
Ok(MinimumVersionDecision::Allow)
} else {
Ok(MinimumVersionDecision::BelowMinimum {
current: parsed_cur.to_string(),
minimum: parsed_min.to_string(),
})
}
}
/// Refuse an explicit install target below the configured floor.
/// Used by `kigi update --version X`.
pub(crate) fn check_install_target(target: &str) -> Result<(), MinimumVersionError> {
let floor = resolve_floor_or_error()?;
check_install_target_inner(target, floor.as_deref())
}
fn check_install_target_inner(
target: &str,
floor: Option<&str>,
) -> Result<(), MinimumVersionError> {
let Some(min) = floor else { return Ok(()) };
match evaluate_minimum_version(target, Some(min))? {
MinimumVersionDecision::Allow => Ok(()),
MinimumVersionDecision::BelowMinimum {
current: target,
minimum,
} => Err(MinimumVersionError::TargetBelowFloor { target, minimum }),
}
}
/// `max(target, configured_floor)`; passthrough when no floor is set.
/// Used by `kigi update` to keep the install target at or above the pin.
pub(crate) fn apply_floor(target: &str) -> Result<String, MinimumVersionError> {
let floor = resolve_floor_or_error()?;
apply_floor_inner(target, floor.as_deref())
}
fn resolve_floor_or_error() -> Result<Option<String>, MinimumVersionError> {
config::resolve_minimum_version()
.map_err(|(value, source)| MinimumVersionError::InvalidMinimum { value, source })
}
fn apply_floor_inner(target: &str, floor: Option<&str>) -> Result<String, MinimumVersionError> {
let Some(min) = floor else {
return Ok(target.to_string());
};
match evaluate_minimum_version(target, Some(min))? {
MinimumVersionDecision::Allow => Ok(target.to_string()),
MinimumVersionDecision::BelowMinimum { minimum, .. } => Ok(minimum),
}
}
/// `max(latest, minimum)`; falls back to `minimum` if `latest` is missing or unparseable.
fn pick_target_version(latest: Option<&str>, minimum: &str) -> String {
match latest.and_then(|v| semver::Version::parse(v).ok()) {
Some(latest_v) => match semver::Version::parse(minimum) {
Ok(min_v) if latest_v >= min_v => latest_v.to_string(),
_ => minimum.to_string(),
},
None => minimum.to_string(),
}
}
/// Call once at startup, before any user-facing UI. On `Ok(Upgraded)` the
/// caller MUST restart. On `Err`, print and exit non-zero.
async fn enforce_minimum_version(
minimum_version: Option<&str>,
update_config: &UpdateConfig,
) -> Result<EnforcementOutcome, MinimumVersionError> {
let current_version = get_installed_kigi_version();
let decision = evaluate_minimum_version(&current_version, minimum_version)?;
let MinimumVersionDecision::BelowMinimum { current, minimum } = decision else {
info!(current = %current_version, "minimum_version: floor satisfied");
return Ok(EnforcementOutcome::Allowed);
};
info!(%current, %minimum, "minimum_version: below floor; attempting auto-update");
// `None` is "default on"; only explicit `false` opts out.
let cfg = config::load_config().await;
if cfg.cli.auto_update == Some(false) {
warn!(%current, %minimum, "minimum_version: auto-update disabled by config");
return Err(MinimumVersionError::AutoUpdateDisabled { current, minimum });
}
let Some(installer) = get_installer().await else {
warn!(%current, %minimum, "minimum_version: no installer detected");
return Err(MinimumVersionError::NoInstaller { current, minimum });
};
let latest = fetch_latest_version(update_config).await.ok();
let target = pick_target_version(latest.as_deref(), &minimum);
info!(%current, %target, installer, "minimum_version: installing upgrade");
eprintln!(
"This version of Kigi ({current}) is no longer supported. \
Updating to {target}…"
);
if let Err(e) = run_install_script(installer, Some(&target), update_config).await {
let detail = format!("{e:#}");
warn!(%current, %target, %detail, "minimum_version: upgrade failed");
return Err(MinimumVersionError::UpgradeFailed {
current,
minimum,
detail,
});
}
write_version_cache(&target, None).await;
// Stale channel pointer or partial install can leave us below the floor;
// surface that rather than starting an out-of-policy binary.
if let MinimumVersionDecision::BelowMinimum { .. } =
evaluate_minimum_version(&target, Some(&minimum))?
{
warn!(%target, %minimum, ?latest, "minimum_version: post-install still below floor");
return Err(match latest {
Some(latest) => MinimumVersionError::NoSatisfyingVersion {
current: target,
minimum,
latest,
},
None => MinimumVersionError::NoReleaseFound {
current: target,
minimum,
},
});
}
info!(%target, "minimum_version: upgrade installed successfully");
Ok(EnforcementOutcome::Upgraded)
}
/// Single chokepoint for the pager + tui startup paths. Re-execs after a
/// floor-driven install. Prints + exits non-zero on `Err`.
///
/// `KIGI_TEST_VERSION` lets devs override the running version to skip
/// enforcement on a `cargo run` build.
pub async fn enforce_minimum_version_or_exit(update_config: &UpdateConfig) {
let min = match resolve_floor_or_error() {
Ok(None) => return,
Ok(Some(m)) => m,
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
match enforce_minimum_version(Some(&min), update_config).await {
Ok(EnforcementOutcome::Allowed) => {}
Ok(EnforcementOutcome::Upgraded) => {
// TODO: restart_kigi uses exec() which carries the same
// SIGABRT risk as the old piped-stderr update path if the
// child process ever writes to a broken pipe. For now this
// path is rare (only fires when the server pushes a minimum
// version bump), so print a relaunch message instead.
eprintln!("Update installed. Run `kigi` to start.");
std::process::exit(0);
}
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn evaluate_minimum_version_decisions() {
use MinimumVersionDecision::{Allow, BelowMinimum};
assert_eq!(evaluate_minimum_version("0.1.100", None).unwrap(), Allow);
assert_eq!(
evaluate_minimum_version("0.1.100", Some("")).unwrap(),
Allow
);
assert_eq!(
evaluate_minimum_version("0.1.100", Some(" ")).unwrap(),
Allow
);
assert_eq!(
evaluate_minimum_version("0.1.100", Some("0.1.100")).unwrap(),
Allow
);
assert_eq!(
evaluate_minimum_version("0.2.0", Some("0.1.100")).unwrap(),
Allow
);
assert!(matches!(
evaluate_minimum_version("0.1.99", Some("0.1.100")).unwrap(),
BelowMinimum { .. }
));
assert!(matches!(
evaluate_minimum_version("0.1.100", Some("not-a-version")),
Err(MinimumVersionError::InvalidMinimum { .. })
));
}
#[test]
fn pick_target_returns_max_of_latest_and_minimum() {
// `None` stands in for a failed `fetch_latest_version`.
assert_eq!(pick_target_version(Some("0.1.200"), "0.1.150"), "0.1.200");
assert_eq!(pick_target_version(Some("0.1.140"), "0.1.150"), "0.1.150");
assert_eq!(pick_target_version(None, "0.1.150"), "0.1.150");
}
#[test]
fn install_target_helpers_consult_floor() {
assert!(check_install_target_inner("0.1.50", None).is_ok());
assert!(check_install_target_inner("0.1.150", Some("0.1.100")).is_ok());
assert!(matches!(
check_install_target_inner("0.1.50", Some("0.1.100")).unwrap_err(),
MinimumVersionError::TargetBelowFloor { .. }
));
assert_eq!(apply_floor_inner("0.1.50", None).unwrap(), "0.1.50");
assert_eq!(
apply_floor_inner("0.1.200", Some("0.1.100")).unwrap(),
"0.1.200"
);
assert_eq!(
apply_floor_inner("0.1.50", Some("0.1.100")).unwrap(),
"0.1.100"
);
}
#[test]
#[serial_test::serial]
fn version_env_var_flows_through_to_decision() {
let saved = std::env::var("KIGI_TEST_VERSION").ok();
// SAFETY: #[serial] excludes other env-touching tests.
unsafe { std::env::set_var("KIGI_TEST_VERSION", "0.1.50") };
let decision =
evaluate_minimum_version(&get_installed_kigi_version(), Some("0.1.100")).unwrap();
assert!(matches!(
decision,
MinimumVersionDecision::BelowMinimum { .. }
));
match saved {
Some(v) => unsafe { std::env::set_var("KIGI_TEST_VERSION", v) },
None => unsafe { std::env::remove_var("KIGI_TEST_VERSION") },
}
}
}