mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-11-12 10:05:24 +08:00
* feat: warn on every command when the CLI is outdated
Every orx command now prints a warning to stderr when the running version
is behind the latest release, because the backend API is versioned and a
stale client can hit breaking changes. The message escalates with the gap:
a breaking (pre-1.0 minor / post-1.0 major) gap warns that commands can
start failing now; a smaller gap is a gentler upgrade nudge.
This reworks the existing interactive-only update nudge:
- Shows on every command (piped/scripted/CI too), not just interactive
terminals; ORX_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER /
OPENRESEARCH_CLI_DISABLE_UPDATE=1 silence it.
- Prints in start() before the command runs, so it shows even for commands
that std::process::exit on their own (e.g. the not-logged-in path).
- Latest version still comes from the cached prior check (instant, zero
latency); a fire-and-forget background task refreshes the cache, given a
brief grace window in finish() to commit before the runtime tears down.
- Cache write is now atomic (temp + rename) so concurrent orx processes
never read a torn file.
- Version comparison ignores build metadata (semver's Ord compares it, the
spec says it shouldn't) to avoid a false 'outdated' on local builds.
Excludes the version/update commands, which manage updates themselves.
* feat: bold the warning label on a TTY and set it off in its own block
The outdated-version warning now prints with a blank line above and below
so it reads as its own block, and bolds just the `Warning:` label
(gh/cargo-style) when stderr is an interactive terminal. Pipes, files, and
CI logs stay plain text — the ANSI is gated on `stderr().is_terminal()`
and honors NO_COLOR — so non-interactive output never gets raw escape codes.
Hand-rolled \x1b[1m/\x1b[22m (no color dependency, matching the codebase's
plain-text style); `render`/`bold` are pure and unit-tested.
* refactor: apply review findings (CI latency, label const, doc cleanup)
From the multi-agent code review:
- finish() now skips the 250ms refresh wait when stderr isn't a terminal.
In ephemeral CI (fresh config dir per run) the cache is always cold, so
the wait warmed a cache that's immediately discarded — pure per-call cost
across a pipeline. The refresh is still spawned; we just don't wait. The
warning still prints on every command. Interactive runs keep the wait so
the cache warms for the next command.
- Hoist a shared WARNING_LABEL const used by both warning_for (builder) and
render (styler), so the bolded label can't silently drift from the emitted
prefix on a copy edit.
- Drop the now-inaccurate "passive" framing from the module header, section
banner, struct doc, and main.rs wiring comment (the warning is always-on).
- s/nudge/update check/ in the version/update command comments.
- Add prerelease-axis tests for warning_for (rc-behind-final is soft, a
cross-minor prerelease is breaking, a local dev prerelease ahead is silent).
* simplify: drop the breaking/soft warning tiers, always show one message
Per request, remove the two-tier distinction entirely. The warning no longer
classifies the version gap as breaking vs. non-breaking — every outdated client
gets the same calmer message:
Warning: orx X is outdated (latest Y). A newer release is available; upgrade
to stay compatible with the API. Run `orx update` to upgrade.
Deletes `is_breaking_gap` (and its RE-CONFIRM-AT-1.0 note), the second message
arm in `warning_for`, and the tier-specific tests; collapses the gap-coverage
tests into one that asserts the single message fires for every kind of gap.
`precedence` (the build-metadata comparison guard) is unrelated and stays.
601 lines
24 KiB
Rust
601 lines
24 KiB
Rust
//! Self-update plumbing shared by `orx version`, `orx update`, and the
|
|
//! outdated-version warning shown on every command.
|
|
//!
|
|
//! Latest-version discovery deliberately avoids the GitHub REST API: its
|
|
//! unauthenticated limit is 60 requests/hour *per IP*, which is routinely
|
|
//! exhausted on the datacenter/NAT addresses agents run from. Instead we fetch
|
|
//! the `dist-manifest.json` asset that cargo-dist uploads to every release via
|
|
//! the documented `releases/latest/download/<asset>` permalink — a plain CDN
|
|
//! redirect with no API rate limit.
|
|
//!
|
|
//! The cargo-dist shell installer writes an install receipt to
|
|
//! `${XDG_CONFIG_HOME:-~/.config}/openresearch-cli/openresearch-cli-receipt.json`.
|
|
//! That receipt is the only thing distinguishing an installer-managed binary
|
|
//! from a `cargo install` one (both live at `~/.cargo/bin/orx` because
|
|
//! dist-workspace.toml sets `install-path = "CARGO_HOME"`), so `orx update`
|
|
//! refuses to touch the binary unless the receipt matches it.
|
|
|
|
use std::io::{IsTerminal, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::OnceLock;
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use semver::Version;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error::{anyhow, Result};
|
|
|
|
/// GitHub repo the released binaries come from.
|
|
pub const REPO_URL: &str = "https://github.com/alphaXiv/openresearch-cli";
|
|
|
|
/// The cargo-dist app name (the *package* name, not the `orx` bin name) — used
|
|
/// in release asset names and the receipt path.
|
|
pub const APP_NAME: &str = "openresearch-cli";
|
|
|
|
/// How long a cached update check stays fresh.
|
|
const CHECK_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
|
|
|
/// Sent on requests to GitHub — some CDNs reject the default (empty) UA.
|
|
const UA: &str = concat!("openresearch-cli/", env!("CARGO_PKG_VERSION"));
|
|
|
|
pub fn current_version() -> Version {
|
|
// The crate version is always valid semver; a panic here is a build bug.
|
|
Version::parse(env!("CARGO_PKG_VERSION")).expect("CARGO_PKG_VERSION is valid semver")
|
|
}
|
|
|
|
fn http() -> &'static reqwest::Client {
|
|
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
|
CLIENT.get_or_init(reqwest::Client::new)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Latest-version discovery (dist-manifest.json)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct LatestRelease {
|
|
pub version: Version,
|
|
/// The git tag (e.g. `v0.1.15`), used to pin asset downloads to the same
|
|
/// release the manifest described.
|
|
pub tag: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct DistManifest {
|
|
announcement_tag: String,
|
|
#[serde(default)]
|
|
releases: Vec<ManifestRelease>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ManifestRelease {
|
|
app_name: String,
|
|
app_version: String,
|
|
}
|
|
|
|
/// Extracts our app's version (and the release tag) from a dist-manifest body.
|
|
fn parse_manifest(body: &str) -> Result<LatestRelease> {
|
|
let manifest: DistManifest = serde_json::from_str(body)?;
|
|
let release = manifest
|
|
.releases
|
|
.iter()
|
|
.find(|r| r.app_name == APP_NAME)
|
|
.ok_or_else(|| anyhow!("Release manifest has no entry for {}", APP_NAME))?;
|
|
let version = Version::parse(&release.app_version)
|
|
.map_err(|e| anyhow!("Could not parse version {:?}: {}", release.app_version, e))?;
|
|
Ok(LatestRelease {
|
|
version,
|
|
tag: manifest.announcement_tag,
|
|
})
|
|
}
|
|
|
|
/// Fetches the latest released version from GitHub (rate-limit-free permalink).
|
|
pub async fn fetch_latest(timeout: Duration) -> Result<LatestRelease> {
|
|
let url = format!("{}/releases/latest/download/dist-manifest.json", REPO_URL);
|
|
let res = http()
|
|
.get(&url)
|
|
.header("user-agent", UA)
|
|
.timeout(timeout)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
anyhow!(
|
|
"Could not fetch the release manifest from {}: {}",
|
|
REPO_URL,
|
|
e
|
|
)
|
|
})?;
|
|
let status = res.status();
|
|
if !status.is_success() {
|
|
let reason = status.canonical_reason().unwrap_or("");
|
|
return Err(anyhow!(
|
|
"Release manifest request failed ({} {})",
|
|
status.as_u16(),
|
|
reason
|
|
));
|
|
}
|
|
let body = res.text().await?;
|
|
parse_manifest(&body)
|
|
}
|
|
|
|
/// Downloads a release asset pinned to `tag` and returns its bytes.
|
|
pub async fn fetch_release_asset(tag: &str, asset: &str, timeout: Duration) -> Result<Vec<u8>> {
|
|
let url = format!("{}/releases/download/{}/{}", REPO_URL, tag, asset);
|
|
let res = http()
|
|
.get(&url)
|
|
.header("user-agent", UA)
|
|
.timeout(timeout)
|
|
.send()
|
|
.await
|
|
.map_err(|e| anyhow!("Could not download {}: {}", url, e))?;
|
|
let status = res.status();
|
|
if !status.is_success() {
|
|
let reason = status.canonical_reason().unwrap_or("");
|
|
return Err(anyhow!(
|
|
"Download of {} failed ({} {})",
|
|
url,
|
|
status.as_u16(),
|
|
reason
|
|
));
|
|
}
|
|
Ok(res.bytes().await?.to_vec())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Install receipt (written by the cargo-dist shell installer)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The fields of the cargo-dist install receipt that `orx update` relies on.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct Receipt {
|
|
pub install_prefix: String,
|
|
pub version: String,
|
|
#[serde(default)]
|
|
pub modify_path: bool,
|
|
}
|
|
|
|
pub fn receipt_path() -> PathBuf {
|
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join(".config")
|
|
});
|
|
base.join(APP_NAME)
|
|
.join(format!("{}-receipt.json", APP_NAME))
|
|
}
|
|
|
|
/// Reads the install receipt. `Ok(None)` when it does not exist (i.e. the
|
|
/// shell installer never ran on this machine); `Err` when it exists but cannot
|
|
/// be parsed, since silently treating a corrupt receipt as "not installed by
|
|
/// the installer" would point users at the wrong update path.
|
|
pub fn load_receipt() -> Result<Option<Receipt>> {
|
|
let path = receipt_path();
|
|
let raw = match std::fs::read_to_string(&path) {
|
|
Ok(raw) => raw,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
Err(e) => return Err(anyhow!("Could not read {}: {}", path.display(), e)),
|
|
};
|
|
let receipt: Receipt = serde_json::from_str(&raw)
|
|
.map_err(|e| anyhow!("Install receipt at {} is malformed: {}", path.display(), e))?;
|
|
Ok(Some(receipt))
|
|
}
|
|
|
|
/// Whether the running executable lives under the receipt's install prefix.
|
|
/// The receipt records the prefix root (e.g. `~/.cargo`), while the binary sits
|
|
/// in its `bin/` subdirectory for the cargo-home layout — so a trailing `bin`
|
|
/// component is stripped from the exe's directory before comparing. Both paths
|
|
/// should be canonicalized by the caller.
|
|
pub fn exe_matches_prefix(exe: &Path, prefix: &Path) -> bool {
|
|
let Some(dir) = exe.parent() else {
|
|
return false;
|
|
};
|
|
let dir = if dir.file_name() == Some(std::ffi::OsStr::new("bin")) {
|
|
dir.parent().unwrap_or(dir)
|
|
} else {
|
|
dir
|
|
};
|
|
dir == prefix
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Update-check cache (the warning's 24h throttle)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct CheckCache {
|
|
/// Unix seconds of the last completed (or attempted) check.
|
|
checked_at: u64,
|
|
/// Latest version seen at that time.
|
|
latest: String,
|
|
}
|
|
|
|
fn cache_path() -> PathBuf {
|
|
crate::config::config_dir().join("update-check.json")
|
|
}
|
|
|
|
fn now_unix() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn read_cache() -> Option<CheckCache> {
|
|
let raw = std::fs::read_to_string(cache_path()).ok()?;
|
|
serde_json::from_str(&raw).ok()
|
|
}
|
|
|
|
/// Best-effort cache write; errors are swallowed because the cache only exists
|
|
/// to throttle a best-effort background check.
|
|
///
|
|
/// The write is atomic (temp file + rename) so a reader never observes a torn
|
|
/// file. Several processes can write this concurrently — the background refresh
|
|
/// here plus `orx version` / `orx update`, and multiple `orx` invocations at
|
|
/// once — and a non-atomic `write` could leave a half-written file that
|
|
/// `read_cache` then parses to `None`, silently suppressing the warning until a
|
|
/// clean write lands. A unique temp name keeps concurrent writers from
|
|
/// clobbering each other's temp file; `rename` is atomic on the same
|
|
/// filesystem, so the final file is always either the old or a complete new one.
|
|
pub fn write_check_cache(latest: &str) {
|
|
let cache = CheckCache {
|
|
checked_at: now_unix(),
|
|
latest: latest.to_string(),
|
|
};
|
|
let path = cache_path();
|
|
let Some(parent) = path.parent() else {
|
|
return;
|
|
};
|
|
let _ = std::fs::create_dir_all(parent);
|
|
let Ok(body) = serde_json::to_string(&cache) else {
|
|
return;
|
|
};
|
|
let tmp = parent.join(format!(".update-check.json.{}.tmp", uuid::Uuid::new_v4()));
|
|
if std::fs::write(&tmp, body).is_err() {
|
|
let _ = std::fs::remove_file(&tmp);
|
|
return;
|
|
}
|
|
if std::fs::rename(&tmp, &path).is_err() {
|
|
// Rename failed (e.g. cross-device, racing cleanup); don't leak the temp.
|
|
let _ = std::fs::remove_file(&tmp);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Outdated-version warning
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The leading label every warning message starts with. Shared so the builder
|
|
/// (`warning_for`) and the styler (`render`, which bolds just this label) can't
|
|
/// silently drift apart on a copy edit.
|
|
const WARNING_LABEL: &str = "Warning:";
|
|
|
|
/// Whether the user has explicitly silenced the update check. When true, no
|
|
/// warning is shown and no background refresh runs — the one escape hatch for
|
|
/// anyone who can't tolerate the extra stderr line (including CI).
|
|
fn opted_out() -> bool {
|
|
std::env::var_os("ORX_NO_UPDATE_CHECK").is_some()
|
|
// The generic convention honored by update-notifier and friends.
|
|
|| std::env::var_os("NO_UPDATE_NOTIFIER").is_some()
|
|
// cargo-dist's own "don't manage updates for this install" switch; the
|
|
// installer already honors it, so it is the one mental model.
|
|
|| std::env::var("OPENRESEARCH_CLI_DISABLE_UPDATE").as_deref() == Ok("1")
|
|
}
|
|
|
|
/// Whether to emit ANSI styling on stderr: only when stderr is a real terminal
|
|
/// and the user hasn't set the conventional `NO_COLOR` opt-out. Pipes, files,
|
|
/// and CI logs get plain text — never raw escape codes — which matters because
|
|
/// this warning now prints on non-interactive runs too.
|
|
fn stderr_supports_ansi() -> bool {
|
|
std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
|
|
}
|
|
|
|
/// Wraps `text` in the ANSI bold sequence when `enabled`, else returns it as-is.
|
|
/// `\x1b[22m` resets bold/faint specifically (not `\x1b[0m`, which would also
|
|
/// clear any surrounding styling the terminal had).
|
|
fn bold(text: &str, enabled: bool) -> String {
|
|
if enabled {
|
|
format!("\x1b[1m{text}\x1b[22m")
|
|
} else {
|
|
text.to_string()
|
|
}
|
|
}
|
|
|
|
/// Renders the warning for printing to stderr: bolds the leading `Warning:`
|
|
/// label when `ansi` is set, and sets the whole thing off in its own block with
|
|
/// a blank line above and below. Returns the exact bytes to write (the caller
|
|
/// uses `write!`, not `writeln!`, since the trailing newlines are included).
|
|
fn render(message: &str, ansi: bool) -> String {
|
|
// The message always begins with WARNING_LABEL (see `warning_for`); bold just
|
|
// that label, gh/cargo-style, rather than the whole sentence. If the prefix
|
|
// ever drifts, fall back to the unstyled message — never panic, never emit a
|
|
// stray escape.
|
|
let styled = match message.strip_prefix(WARNING_LABEL) {
|
|
Some(rest) => format!("{}{rest}", bold(WARNING_LABEL, ansi)),
|
|
None => message.to_string(),
|
|
};
|
|
format!("\n{styled}\n\n")
|
|
}
|
|
|
|
/// Version-precedence key: everything that orders two releases *except* build
|
|
/// metadata. Per the SemVer spec build metadata is not part of precedence
|
|
/// (`1.0.0+a` and `1.0.0+b` are the same release), but `semver::Version`'s `Ord`
|
|
/// compares it anyway — so two builds of the same release would otherwise read
|
|
/// as "outdated". Comparing this key instead avoids that false positive.
|
|
/// `Prerelease`'s own `Ord` already encodes the spec rule that a release sorts
|
|
/// above its pre-releases (empty pre-release is the greatest).
|
|
fn precedence(v: &Version) -> (u64, u64, u64, &semver::Prerelease) {
|
|
(v.major, v.minor, v.patch, &v.pre)
|
|
}
|
|
|
|
/// Builds the outdated-version warning when `latest` outranks `current` in
|
|
/// [`precedence`], or `None` when `current` is already current (or ahead — a
|
|
/// local dev build, or the same release with different build metadata).
|
|
///
|
|
/// Because orx talks to a versioned backend, a stale client can hit removed or
|
|
/// changed API shapes, so the warning notes the compatibility risk rather than a
|
|
/// neutral "new version available".
|
|
fn warning_for(current: &Version, latest: &Version) -> Option<String> {
|
|
if precedence(latest) <= precedence(current) {
|
|
return None;
|
|
}
|
|
Some(format!(
|
|
"{WARNING_LABEL} orx {current} is outdated (latest {latest}). A newer release is \
|
|
available; upgrade to stay compatible with the API. Run `orx update` to upgrade."
|
|
))
|
|
}
|
|
|
|
/// The outdated-version warning, modeled on the gh CLI / update-notifier
|
|
/// pattern: the message shown this run comes from the *cached* previous check,
|
|
/// so it is instant and never adds latency to the command. A background refresh
|
|
/// updates the cache for the next run at most once per [`CHECK_TTL`].
|
|
///
|
|
/// Unlike a quiet "new version" nudge, the warning is shown on every command —
|
|
/// piped, scripted, or interactive — and is printed in [`start`](Self::start),
|
|
/// *before* the command runs, so it survives commands that `std::process::exit`
|
|
/// on their own (e.g. the "not logged in" path) instead of returning to `main`.
|
|
/// [`opted_out`] is the single switch to silence it.
|
|
///
|
|
/// Because the message comes from cache, the *first* run after a fresh install
|
|
/// has nothing cached yet and shows nothing; the background refresh it kicks off
|
|
/// warms the cache so a later run can warn. The refresh is always fire-and-forget
|
|
/// — it never blocks the command — so a one-shot environment that throws its
|
|
/// cache away each run (e.g. an ephemeral CI container) may take a few runs to
|
|
/// warm up, or never. That is the accepted cost of adding zero latency.
|
|
pub struct UpdateWarning {
|
|
refresh: Option<tokio::task::JoinHandle<()>>,
|
|
}
|
|
|
|
impl UpdateWarning {
|
|
/// Prints the cached warning (if any) to stderr immediately, then kicks off a
|
|
/// best-effort background refresh of the cached "latest" for next time.
|
|
/// Printing here — rather than after the command — is what guarantees the
|
|
/// warning shows even when the command exits the process itself.
|
|
pub fn start() -> UpdateWarning {
|
|
if opted_out() {
|
|
return UpdateWarning { refresh: None };
|
|
}
|
|
|
|
let current = current_version();
|
|
let cache = read_cache();
|
|
|
|
if let Some(message) = cache
|
|
.as_ref()
|
|
.and_then(|c| Version::parse(&c.latest).ok())
|
|
.and_then(|latest| warning_for(¤t, &latest))
|
|
{
|
|
// Infallible: a closed/broken stderr (e.g. `2>&-`, or a reader that
|
|
// already exited) must not panic the process before the command even
|
|
// runs. `eprintln!` would; a swallowed `writeln!` won't.
|
|
let _ = write!(
|
|
std::io::stderr(),
|
|
"{}",
|
|
render(&message, stderr_supports_ansi())
|
|
);
|
|
}
|
|
|
|
// Refresh whenever the cache is stale (or absent), regardless of whether
|
|
// stderr is a terminal: scripted/piped runs warn too, so their cache has
|
|
// to stay fresh or they'd warn off a frozen answer indefinitely.
|
|
let stale = cache
|
|
.as_ref()
|
|
.map(|c| now_unix().saturating_sub(c.checked_at) >= CHECK_TTL.as_secs())
|
|
.unwrap_or(true);
|
|
if !stale {
|
|
return UpdateWarning { refresh: None };
|
|
}
|
|
|
|
let prev_latest = cache.map(|c| c.latest);
|
|
let handle = tokio::spawn(async move {
|
|
let latest = fetch_latest(Duration::from_secs(3))
|
|
.await
|
|
.ok()
|
|
.map(|l| l.version.to_string());
|
|
// On fetch failure, refresh checked_at with the old answer so errors
|
|
// don't cause a retry on every invocation. Only fabricate `current`
|
|
// as a last resort (no cache, no previous answer): a one-off failed
|
|
// fetch then suppresses the warning until the TTL lapses, which is
|
|
// the price of not re-fetching on every offline invocation.
|
|
let value = latest
|
|
.or(prev_latest)
|
|
.unwrap_or_else(|| current.to_string());
|
|
write_check_cache(&value);
|
|
});
|
|
|
|
UpdateWarning {
|
|
refresh: Some(handle),
|
|
}
|
|
}
|
|
|
|
/// Called after the real command finished. On an interactive terminal, grants
|
|
/// the fire-and-forget refresh a brief grace window to land in the cache — it
|
|
/// has often finished during the command's own work — so the cache is warm for
|
|
/// the user's *next* command; the window gives the write a chance to commit
|
|
/// before `#[tokio::main]` tears the runtime down, without which quick commands
|
|
/// would never warm the cache.
|
|
///
|
|
/// On a non-terminal (pipe, CI, cron) the wait is skipped entirely: an
|
|
/// ephemeral environment throws its cache away between runs, so warming it
|
|
/// buys nothing, and a per-invocation 250ms tail across a CI pipeline that
|
|
/// calls `orx` many times is pure cost. The refresh is still spawned (it may
|
|
/// land if the process outlives it); we just don't pay to wait for it. Never
|
|
/// blocks the command meaningfully, never touches stdout or the exit code.
|
|
pub async fn finish(self) {
|
|
let Some(handle) = self.refresh else {
|
|
return;
|
|
};
|
|
if !std::io::stderr().is_terminal() {
|
|
return;
|
|
}
|
|
// Timed out -> the task keeps running (it may still land); the next stale
|
|
// run tries again regardless.
|
|
let _ = tokio::time::timeout(Duration::from_millis(250), handle).await;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{bold, exe_matches_prefix, parse_manifest, precedence, render, warning_for};
|
|
use semver::Version;
|
|
use std::path::Path;
|
|
|
|
#[test]
|
|
fn render_sets_off_the_warning_in_its_own_block() {
|
|
let msg = "Warning: orx 0.1.0 is outdated (latest 0.2.0). foo Run `orx update` to upgrade.";
|
|
// Plain (non-TTY): blank line before and after, no escape codes at all.
|
|
let plain = render(msg, false);
|
|
assert_eq!(plain, format!("\n{msg}\n\n"));
|
|
assert!(
|
|
!plain.contains('\x1b'),
|
|
"plain output must not contain ANSI"
|
|
);
|
|
|
|
// Styled (TTY): only the "Warning:" label is bolded; same blank-line block.
|
|
let styled = render(msg, true);
|
|
assert!(styled.starts_with("\n\x1b[1mWarning:\x1b[22m"));
|
|
assert!(styled.ends_with("upgrade.\n\n"));
|
|
// Everything after the label is unstyled — exactly one bold open/close.
|
|
assert_eq!(styled.matches("\x1b[1m").count(), 1);
|
|
assert_eq!(styled.matches("\x1b[22m").count(), 1);
|
|
|
|
// Fail-soft: a message without the "Warning:" prefix is passed through
|
|
// unstyled (no panic, no stray escapes), so the label/builder coupling
|
|
// degrades gracefully if the prefix ever changes.
|
|
let no_label = render("orx is outdated.", true);
|
|
assert_eq!(no_label, "\norx is outdated.\n\n");
|
|
}
|
|
|
|
#[test]
|
|
fn bold_is_a_noop_when_disabled() {
|
|
assert_eq!(bold("x", false), "x");
|
|
assert_eq!(bold("x", true), "\x1b[1mx\x1b[22m");
|
|
}
|
|
|
|
#[test]
|
|
fn precedence_ignores_build_metadata_and_orders_prereleases() {
|
|
let v = |s: &str| Version::parse(s).unwrap();
|
|
// Build metadata is not part of precedence: same release.
|
|
assert_eq!(precedence(&v("1.2.3+a")), precedence(&v("1.2.3+b")));
|
|
assert_eq!(precedence(&v("1.2.3")), precedence(&v("1.2.3+build.99")));
|
|
// A release outranks its pre-releases (empty pre-release is greatest).
|
|
assert!(precedence(&v("0.2.0")) > precedence(&v("0.2.0-rc.1")));
|
|
assert!(precedence(&v("0.2.0-rc.2")) > precedence(&v("0.2.0-rc.1")));
|
|
// Ordinary ordering on the numeric fields still holds.
|
|
assert!(precedence(&v("0.2.0")) > precedence(&v("0.1.29")));
|
|
}
|
|
|
|
// Every outdated case shows the same message; assert its stable markers
|
|
// without pinning the exact copy.
|
|
fn assert_warns(msg: &str) {
|
|
assert!(msg.contains("outdated"), "{msg}");
|
|
assert!(msg.contains("stay compatible with the API"), "{msg}");
|
|
assert!(msg.contains("orx update"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn no_warning_when_current_or_ahead() {
|
|
let v = |s: &str| Version::parse(s).unwrap();
|
|
// Exactly current.
|
|
assert!(warning_for(&v("0.1.29"), &v("0.1.29")).is_none());
|
|
// Local build ahead of the latest release.
|
|
assert!(warning_for(&v("0.2.0"), &v("0.1.29")).is_none());
|
|
// Build metadata is ignored by semver ordering, so it's not "outdated".
|
|
assert!(warning_for(&v("0.1.29"), &v("0.1.29+build.5")).is_none());
|
|
// Running ahead of the latest stable on a local pre-release: not outdated.
|
|
assert!(warning_for(&v("0.3.0-dev.1"), &v("0.2.0")).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn warns_on_every_kind_of_outdated_gap() {
|
|
let v = |s: &str| Version::parse(s).unwrap();
|
|
// The same warning fires regardless of how far behind: patch, minor,
|
|
// major, 0.0.x, post-1.0, and a pre-release behind its final release.
|
|
for (cur, latest) in [
|
|
("0.1.28", "0.1.29"), // pre-1.0 patch
|
|
("0.1.29", "0.2.0"), // pre-1.0 minor
|
|
("0.0.1", "0.0.2"), // 0.0.x patch
|
|
("0.9.0", "1.0.0"), // 0.x -> 1.0
|
|
("1.2.3", "1.2.4"), // post-1.0 patch
|
|
("1.2.3", "1.3.0"), // post-1.0 minor
|
|
("1.4.0", "2.0.0"), // major
|
|
("0.2.0-rc.1", "0.2.0"), // pre-release behind its final release
|
|
("0.1.29", "0.2.0-rc.1"), // behind the next minor's pre-release
|
|
] {
|
|
assert_warns(&warning_for(&v(cur), &v(latest)).unwrap_or_else(|| {
|
|
panic!("expected a warning for {cur} -> {latest}");
|
|
}));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_dist_manifest() {
|
|
let body = r#"{
|
|
"dist_version": "0.32.0",
|
|
"announcement_tag": "v0.1.15",
|
|
"announcement_is_prerelease": false,
|
|
"releases": [{
|
|
"app_name": "openresearch-cli",
|
|
"app_version": "0.1.15",
|
|
"artifacts": ["openresearch-cli-installer.sh"]
|
|
}]
|
|
}"#;
|
|
let latest = parse_manifest(body).unwrap();
|
|
assert_eq!(latest.tag, "v0.1.15");
|
|
assert_eq!(latest.version, Version::new(0, 1, 15));
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_without_our_app_is_an_error() {
|
|
let body = r#"{"announcement_tag": "v1.0.0", "releases": [{"app_name": "other", "app_version": "1.0.0"}]}"#;
|
|
assert!(parse_manifest(body).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn semver_ordering_not_lexicographic() {
|
|
// The case string comparison gets wrong.
|
|
assert!(Version::parse("0.1.15").unwrap() > Version::parse("0.1.9").unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn exe_prefix_matching() {
|
|
let cases = [
|
|
// cargo-home layout: receipt prefix is the root, binary in bin/.
|
|
("/home/u/.cargo/bin/orx", "/home/u/.cargo", true),
|
|
// flat layout: binary directly in the prefix.
|
|
("/opt/orx/orx", "/opt/orx", true),
|
|
// foreign binary elsewhere on PATH.
|
|
("/usr/local/bin/orx", "/home/u/.cargo", false),
|
|
];
|
|
for (exe, prefix, want) in cases {
|
|
assert_eq!(
|
|
exe_matches_prefix(Path::new(exe), Path::new(prefix)),
|
|
want,
|
|
"exe={} prefix={}",
|
|
exe,
|
|
prefix
|
|
);
|
|
}
|
|
}
|
|
}
|