diff --git a/src/app.rs b/src/app.rs index e7a0c762..5977c91a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,7 +22,7 @@ use crate::session::manager::SessionManager; use crate::tools::{PermissionResponse, ToolHandler}; use crate::push_toast; -use crate::toast::{self, Toast, ToastLevel}; +use crate::toast::{self, Toast, ToastAction, ToastLevel}; use crate::ui::components::action_dialog::{ActionDialog, ActionDialogEvent, ActionDialogItem}; use crate::ui::components::chat::{Chat, ChatImageTarget}; use crate::ui::components::find::{FindBar, FindBarAction}; @@ -943,6 +943,16 @@ pub struct App { Option>, btw_receiver: Option>, btw_entries: Vec, + /// Background update-check channel; `Some` while the lazy check is in flight. + /// Keeps the event loop fast-polling so the toast appears promptly. + update_check_receiver: + Option>, + /// Set once the lazy check has started (or been skipped) — one check per process. + update_check_started: bool, + /// Background upgrade channel; `Some` while `crabcode upgrade` runs. + upgrade_receiver: Option>, + /// Guards against double-running the upgrade from repeated toast clicks. + upgrade_in_progress: bool, /// Lines scrolled down from the top inside the `/btw` panel (0 = top). btw_scroll: usize, /// Last-rendered `/btw` panel rect, for mouse-wheel hit-testing. @@ -1213,6 +1223,10 @@ impl App { title_generation_receiver: None, btw_receiver: None, btw_entries: Vec::new(), + update_check_receiver: None, + update_check_started: false, + upgrade_receiver: None, + upgrade_in_progress: false, btw_scroll: 0, btw_panel_area: None, prefs_dao, @@ -5311,6 +5325,10 @@ impl App { self.input.clear_hover(); } + if self.handle_update_toast_mouse(mouse) { + return; + } + if self.handle_error_toast_mouse(mouse) { return; } @@ -6703,6 +6721,184 @@ impl App { } } + /// Lazy update check: once per process, after first paint. A fresh 24h + /// cache shows the toast synchronously (no thread, no network); otherwise + /// one blocking lookup runs off-thread. Check failures are silent. + /// `CRABCODE_FORCE_UPDATE_NOTICE` previews the toast with no network; + /// `CRABCODE_NO_UPDATE_CHECK` disables everything, including the preview. + pub fn maybe_start_update_check(&mut self) { + if self.update_check_started || self.update_check_receiver.is_some() { + return; + } + self.update_check_started = true; + // UI-testing preview: force the toast without cache/network/version + // checks. Never auto-upgrades; a click still runs the normal flow. + // Disable wins over force. + let disabled = crate::update::update_check_disabled(); + if crate::update::forced_preview_active(disabled, crate::update::force_update_notice()) { + push_toast(Toast::update_available()); + return; + } + if disabled { + return; + } + + let current = crate::upgrade::current_version(); + if let Some(_latest) = crate::update::load_cached_update(¤t) { + push_toast(Toast::update_available()); + return; + } + if !crate::update::should_fetch_update() { + // Fresh cache, already current — respect the 24h TTL. + return; + } + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + self.update_check_receiver = Some(receiver); + crate::update::spawn_detached_update_worker("crabcode-update-check", sender, move || { + crate::update::check_and_cache(¤t) + }); + } + + /// Drain the background version check. Returns true when a toast was + /// pushed so the event loop can redraw once (no 60fps animation). + fn process_update_check_events(&mut self) -> bool { + let mut latest_available: Option = None; + let mut disconnected = false; + + if let Some(receiver) = &mut self.update_check_receiver { + loop { + match receiver.try_recv() { + Ok(result) => { + if latest_available.is_none() { + latest_available = result; + } + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + + if disconnected || latest_available.is_some() { + self.update_check_receiver = None; + } + + if let Some(_latest) = latest_available { + push_toast(Toast::update_available()); + return true; + } + // Disconnects and `None` results stay silent by design. + false + } + + /// Start `crabcode upgrade` off-thread after an explicit toast click. + /// Retires the nudge first so it cannot double-run; the result arrives via + /// [`Self::process_upgrade_events`] as `Updated · Restart to apply` or an + /// error toast. No confirm dialog, no auto-restart, no forced exit. + fn start_upgrade_from_toast(&mut self) { + if self.upgrade_in_progress || self.upgrade_receiver.is_some() { + return; + } + get_toast_manager() + .lock() + .unwrap() + .remove_action(ToastAction::Upgrade); + self.upgrade_in_progress = true; + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + self.upgrade_receiver = Some(receiver); + // Intentionally detached std thread (not the Tokio blocking pool): + // Tokio waits indefinitely on shutdown for started spawn_blocking + // tasks even if the JoinHandle is dropped, so a minutes-long install + // would hang quit. A detached OS thread is untracked by the runtime, + // so quitting mid-upgrade stays immediate. Timeouts (`--max-time`), + // null stdin, and no-prompt env bound every network/prompt wait; the + // installer child is reparented if the TUI exits first and either + // completes or fails silently — never a zombie under the TUI + // (captured `output()` reaps while attached). No auto-restart. + crate::update::spawn_detached_update_worker("crabcode-upgrade", sender, move || { + crate::upgrade::upgrade_noninteractive(None).map_err(|err| format!("{err:#}")) + }); + } + + /// Drain the background upgrade. Returns true when a toast was pushed so + /// the event loop can redraw once (no 60fps animation). + fn process_upgrade_events(&mut self) -> bool { + let mut outcomes = Vec::new(); + let mut disconnected = false; + + if let Some(receiver) = &mut self.upgrade_receiver { + loop { + match receiver.try_recv() { + Ok(outcome) => outcomes.push(outcome), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + + if disconnected || !outcomes.is_empty() { + self.upgrade_receiver = None; + self.upgrade_in_progress = false; + } + + let had_outcomes = !outcomes.is_empty(); + for outcome in outcomes { + match outcome { + Ok(_version) => push_toast(Toast::updated()), + Err(err) => push_toast(Toast::upgrade_failed( + crate::update::upgrade_failure_message(&err), + )), + } + } + + if disconnected && !had_outcomes { + push_toast(Toast::upgrade_failed( + "Update failed: background task ended", + )); + return true; + } + had_outcomes + } + + /// Clicking the `New version available · Upgrade` toast explicitly runs + /// `crabcode upgrade`. The whole toast is the Upgrade affordance. Other + /// clicks on the toast are swallowed so they don't fall through to chat. + fn handle_update_toast_mouse(&mut self, mouse: MouseEvent) -> bool { + let hit = { + let manager = get_toast_manager().lock().unwrap(); + manager.action_at(self.last_frame_size, Position::new(mouse.column, mouse.row)) + }; + let Some((action, _message)) = hit else { + return false; + }; + if !matches!(action, ToastAction::Upgrade) { + return false; + } + + if matches!( + mouse.kind, + MouseEventKind::ScrollDown | MouseEventKind::ScrollUp + ) { + return false; + } + + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && mouse.modifiers.is_empty() + { + self.start_upgrade_from_toast(); + } + + true + } + fn handle_error_toast_mouse(&mut self, mouse: MouseEvent) -> bool { let copied = { let manager = get_toast_manager().lock().unwrap(); @@ -10181,6 +10377,11 @@ impl App { || self.storage_receiver.is_some() || self.models_receiver.is_some() || self.title_generation_receiver.is_some() + // NOTE: update_check/upgrade receivers are intentionally *not* + // animation: a 10s version lookup or minutes-long `cargo install` + // must not pin 60fps full renders. Completion wakes via bounded + // background poll (see `has_pending_update_work`) plus one redraw + // when the toast lands. || self.terminal_session_dialog_state.has_active() || self .session_view_states @@ -10194,6 +10395,14 @@ impl App { || self.jobs_dialog_state.is_visible() } + /// Background update/upgrade in flight (version lookup or installer). + /// The event loop uses this for a bounded non-animation poll (~10Hz, + /// no renders) so completion lands promptly without 60fps churn and + /// without blocking startup (check starts after first paint). + pub fn has_pending_update_work(&self) -> bool { + self.update_check_receiver.is_some() || self.upgrade_receiver.is_some() + } + fn sessions_dialog_has_streaming_rows(&self) -> bool { if let Some(signature) = self.sessions_dialog_state.last_list_signature.as_ref() { return signature.rows.iter().any(|row| row.is_streaming); @@ -10302,9 +10511,14 @@ impl App { input_scrolled || chat_scrolled } - pub fn process_streaming_chunks(&mut self) { + /// Drain background channels + streams. Returns true when an update or + /// upgrade toast landed so the event loop can redraw once (idle wakeup + /// path has no animation to carry the repaint). + pub fn process_streaming_chunks(&mut self) -> bool { self.process_provider_oauth_events(); self.process_mcp_oauth_events(); + let update_toasted = self.process_update_check_events(); + let upgrade_toasted = self.process_upgrade_events(); self.process_compaction_events(); self.process_storage_events(); self.process_models_events(); @@ -10366,6 +10580,7 @@ impl App { self.sync_active_streaming_flag(); self.update_sessions_dialog_live_state(false); + update_toasted || upgrade_toasted } fn process_streaming_chunk_for_session( @@ -13008,6 +13223,27 @@ mod tests { use crate::tools::{PermissionAction, PermissionPrompt}; use serde_json::json; + /// Serializes the preview-flag env tests so concurrent cases cannot swap + /// `CRABCODE_FORCE_UPDATE_NOTICE` / `CRABCODE_NO_UPDATE_CHECK` mid-call. + fn update_preview_env_lock() -> &'static std::sync::Mutex<()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + &LOCK + } + + fn restore_env_var(key: &str, prev: Option) { + match prev { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } + + fn restore_env_os(key: &str, prev: Option) { + match prev { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } + fn test_app() -> App { let mut registry = Registry::new(); register_all_commands(&mut registry); @@ -13079,6 +13315,10 @@ mod tests { title_generation_receiver: None, btw_receiver: None, btw_entries: Vec::new(), + update_check_receiver: None, + update_check_started: false, + upgrade_receiver: None, + upgrade_in_progress: false, btw_scroll: 0, btw_panel_area: None, prefs_dao: None, @@ -13705,6 +13945,205 @@ mod tests { } } + #[test] + fn upgrade_success_event_clears_in_flight_state() { + let mut app = test_app(); + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + app.upgrade_receiver = Some(receiver); + app.upgrade_in_progress = true; + + sender.send(Ok("0.0.13".to_string())).unwrap(); + assert!( + app.process_upgrade_events(), + "upgrade toast must request a redraw" + ); + + assert!(app.upgrade_receiver.is_none()); + assert!(!app.upgrade_in_progress); + } + + #[test] + fn upgrade_failure_event_clears_in_flight_state() { + let mut app = test_app(); + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + app.upgrade_receiver = Some(receiver); + app.upgrade_in_progress = true; + + sender.send(Err("boom".to_string())).unwrap(); + assert!( + app.process_upgrade_events(), + "failure toast must request a redraw" + ); + + assert!(app.upgrade_receiver.is_none()); + assert!(!app.upgrade_in_progress); + } + + #[test] + fn upgrade_disconnect_without_result_clears_in_flight_state() { + let mut app = test_app(); + let (sender, receiver) = + tokio::sync::mpsc::unbounded_channel::(); + app.upgrade_receiver = Some(receiver); + app.upgrade_in_progress = true; + drop(sender); + + assert!( + app.process_upgrade_events(), + "disconnect toast must request a redraw" + ); + + assert!(app.upgrade_receiver.is_none()); + assert!(!app.upgrade_in_progress); + } + + #[test] + fn pending_update_work_does_not_pin_animation_loop() { + // Merge-blocker regression: a 10s lookup or minutes-long install must + // use the bounded background poll, not 60fps full renders. + let mut app = test_app(); + app.base_focus = BaseFocus::Chat; + assert!(!app.has_pending_update_work()); + assert!(!app.is_animation_running()); + + let (_s1, r1) = tokio::sync::mpsc::unbounded_channel::(); + app.update_check_receiver = Some(r1); + assert!(app.has_pending_update_work()); + assert!( + !app.is_animation_running(), + "update check must not force animation" + ); + app.update_check_receiver = None; + + let (_s2, r2) = tokio::sync::mpsc::unbounded_channel::(); + app.upgrade_receiver = Some(r2); + app.upgrade_in_progress = true; + assert!(app.has_pending_update_work()); + assert!( + !app.is_animation_running(), + "upgrade install must not force animation" + ); + } + + #[test] + fn update_check_event_reports_redraw_only_on_toast() { + let mut app = test_app(); + // No receiver => no toast, no redraw. + assert!(!app.process_update_check_events()); + // Available update => toast + redraw. + let (sender, receiver) = + tokio::sync::mpsc::unbounded_channel::(); + app.update_check_receiver = Some(receiver); + sender.send(Some("9.9.9".to_string())).unwrap(); + assert!(app.process_update_check_events()); + assert!(app.update_check_receiver.is_none()); + crate::get_toast_manager() + .lock() + .unwrap() + .remove_action(crate::toast::ToastAction::Upgrade); + } + + #[test] + fn update_check_already_started_is_noop() { + let mut app = test_app(); + app.update_check_started = true; + app.maybe_start_update_check(); + assert!(app.update_check_receiver.is_none()); + } + + #[test] + fn forced_preview_marks_started_without_spawning_fetch() { + // Preview bypasses cache/network/version checks and never auto-runs + // the upgrade; the forced path returns before any background task, so + // no tokio runtime is needed here. + let _guard = update_preview_env_lock().lock().unwrap(); + let prev_force = std::env::var("CRABCODE_FORCE_UPDATE_NOTICE").ok(); + let prev_disable = std::env::var_os("CRABCODE_NO_UPDATE_CHECK"); + std::env::set_var("CRABCODE_FORCE_UPDATE_NOTICE", "1"); + std::env::remove_var("CRABCODE_NO_UPDATE_CHECK"); + + let mut app = test_app(); + app.maybe_start_update_check(); + + assert!(app.update_check_started); + assert!(app.update_check_receiver.is_none()); + + restore_env_var("CRABCODE_FORCE_UPDATE_NOTICE", prev_force); + restore_env_os("CRABCODE_NO_UPDATE_CHECK", prev_disable); + crate::get_toast_manager() + .lock() + .unwrap() + .remove_action(crate::toast::ToastAction::Upgrade); + } + + #[test] + fn disable_wins_over_forced_preview() { + let _guard = update_preview_env_lock().lock().unwrap(); + let prev_force = std::env::var("CRABCODE_FORCE_UPDATE_NOTICE").ok(); + let prev_disable = std::env::var_os("CRABCODE_NO_UPDATE_CHECK"); + std::env::set_var("CRABCODE_FORCE_UPDATE_NOTICE", "1"); + std::env::set_var("CRABCODE_NO_UPDATE_CHECK", "1"); + + // Helper-level precedence plus wiring: disabled short-circuits before + // any fetch is spawned. + assert!(!crate::update::forced_preview_active(true, true)); + let mut app = test_app(); + app.maybe_start_update_check(); + + assert!(app.update_check_started); + assert!(app.update_check_receiver.is_none()); + + restore_env_var("CRABCODE_FORCE_UPDATE_NOTICE", prev_force); + restore_env_os("CRABCODE_NO_UPDATE_CHECK", prev_disable); + crate::get_toast_manager() + .lock() + .unwrap() + .remove_action(crate::toast::ToastAction::Upgrade); + } + + #[test] + fn update_toast_click_is_consumed_while_upgrade_in_flight() { + // Guard path only: upgrade already running, so no task spawns (unit + // tests have no tokio runtime). Proves the click hits the Upgrade + // toast and is swallowed instead of falling through to chat. + // + // The global toast manager is shared with parallel tests, so re-push + // our nudge (making it newest/visible) and retry the scan+click pair + // if a concurrent push crowds it out of the visible window. + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + app.upgrade_in_progress = true; + + let frame = ratatui::layout::Rect::new(0, 0, 80, 24); + let mut consumed = false; + for _ in 0..50 { + crate::push_toast(crate::toast::Toast::update_available()); + let hit = (0..24).find_map(|y| { + (0..80).find_map(|x| { + let at = crate::get_toast_manager() + .lock() + .unwrap() + .action_at(frame, ratatui::layout::Position::new(x, y)); + matches!(at, Some((crate::toast::ToastAction::Upgrade, _))).then_some((x, y)) + }) + }); + let Some((x, y)) = hit else { continue }; + if app.handle_update_toast_mouse(mouse(MouseEventKind::Down(MouseButton::Left), x, y)) { + consumed = true; + break; + } + } + assert!(consumed, "update toast click should be consumed"); + // In-flight guard: no new channel, still marked in progress. + assert!(app.upgrade_receiver.is_none()); + assert!(app.upgrade_in_progress); + + crate::get_toast_manager() + .lock() + .unwrap() + .remove_action(crate::toast::ToastAction::Upgrade); + } + #[test] fn jobs_dialog_click_outside_clears_overlay_focus() { // Regression: Dialog::handle_mouse_event hides on outside click and diff --git a/src/main.rs b/src/main.rs index 78bf16dc..852eec7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ mod theme; mod toast; mod tools; mod ui; +mod update; mod upgrade; mod utils; mod views; @@ -688,6 +689,20 @@ pub fn remove_expired_toasts() { TOAST_MANAGER.lock().unwrap().remove_expired(); } +/// Drop expired toasts, reporting whether a redraw is needed. Separated from +/// the void helper so the event loop can repaint exactly once at expiry +/// without continuously animating. +fn remove_expired_toasts_needs_redraw() -> bool { + TOAST_MANAGER.lock().unwrap().remove_expired() +} + +/// How long until the next toast expires (for idle wakeup). Caps the idle +/// `poll()` so a 4s toast wakes one redraw at expiry instead of lingering +/// painted with a dead hitbox. +fn time_until_next_toast_expiry() -> Option { + TOAST_MANAGER.lock().unwrap().time_until_next_expiry() +} + pub fn get_toast_manager() -> &'static Mutex { &TOAST_MANAGER } @@ -1591,6 +1606,10 @@ async fn run_event_loop( // A short "idle" poll still burns needless redraws/sec; block until input instead. const FAST_POLL: Duration = Duration::from_millis(16); // ~60fps for interactive animations const STREAMING_POLL: Duration = Duration::from_millis(40); // 25fps, matches wave spinner + // Background update/upgrade completion poll: wakes to drain the channel + // without rendering (10Hz, no frames). Far cheaper than pinning 60fps + // full renders for a 10s lookup or minutes-long install. + const BACKGROUND_POLL: Duration = Duration::from_millis(100); const IDLE_POLL: Duration = Duration::from_secs(30); // wake only on input / timeout let mut needs_redraw = true; @@ -1601,14 +1620,24 @@ async fn run_event_loop( let loop_start = std::time::Instant::now(); let animation_needed = app.is_animation_running(); + let background_pending = app.has_pending_update_work(); - let poll_duration = if animation_needed && app.is_streaming_animation_only() { + let base_poll = if animation_needed && app.is_streaming_animation_only() { STREAMING_POLL } else if animation_needed { FAST_POLL + } else if background_pending { + BACKGROUND_POLL } else { IDLE_POLL }; + // Cap idle/background waits at the next toast expiry so a 4s toast + // wakes exactly one redraw at expiry (no stale paint + dead hitbox, + // no continuous animation). + let poll_duration = match time_until_next_toast_expiry() { + Some(until_expiry) => base_poll.min(until_expiry), + None => base_poll, + }; let elapsed_before_poll = loop_start.elapsed(); let poll_timeout = if needs_redraw { @@ -1757,10 +1786,18 @@ async fn run_event_loop( needs_redraw = true; } - app.process_streaming_chunks(); + // Background update/upgrade completion lands a toast: redraw once even + // when idle (no animation/input to carry the repaint). + if app.process_streaming_chunks() { + needs_redraw = true; + } app.update_animations(); app.update_terminal_title_signal(); - remove_expired_toasts(); + // Toast expiry also needs exactly one redraw: without it the last + // frame stays painted while hit-testing already reports expired. + if remove_expired_toasts_needs_redraw() { + needs_redraw = true; + } let isolated_spinner_interval = app.isolated_subagent_spinner_interval(); let full_render_due = isolated_spinner_interval.is_none_or(|interval| { last_complete_frame.is_none() || last_full_render_at.elapsed() >= interval @@ -1810,6 +1847,8 @@ async fn run_event_loop( session_history_loaded = true; needs_redraw = true; } + // Lazy nonblocking update check (24h cache, silent failures). + app.maybe_start_update_check(); } } Ok(()) diff --git a/src/remote/mod.rs b/src/remote/mod.rs index 02586dad..1c3a7890 100644 --- a/src/remote/mod.rs +++ b/src/remote/mod.rs @@ -1217,7 +1217,7 @@ fn tick_remote_host_app(app: &mut App) { )); } } - app.process_streaming_chunks(); + let _ = app.process_streaming_chunks(); app.update_animations(); crate::remove_expired_toasts(); } diff --git a/src/toast.rs b/src/toast.rs index 796f52d6..db211ad4 100644 --- a/src/toast.rs +++ b/src/toast.rs @@ -48,11 +48,25 @@ impl ToastLevel { } } +/// Ephemeral toast shown when a cached version check finds a newer release. +/// Clicking it explicitly starts `crabcode upgrade`. Keep exact. +pub const UPDATE_AVAILABLE_MESSAGE: &str = "New version available · Upgrade"; + +/// Toast shown after `crabcode upgrade` finishes. Keep exact. +pub const UPDATED_MESSAGE: &str = "Updated · Restart to apply"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastAction { + /// Clicking the toast runs `crabcode upgrade` (no confirm, no auto-install). + Upgrade, +} + #[derive(Debug, Clone)] pub struct Toast { message: String, level: ToastLevel, expires_at: Instant, + action: Option, } impl Toast { @@ -62,9 +76,43 @@ impl Toast { message: message.into(), level, expires_at: Instant::now() + duration, + action: None, } } + pub fn with_action(mut self, action: ToastAction) -> Self { + self.action = Some(action); + self + } + + /// Ephemeral update nudge. Info level so it never becomes copyable text. + pub fn update_available() -> Self { + Self::new(UPDATE_AVAILABLE_MESSAGE, ToastLevel::Info, None) + .with_action(ToastAction::Upgrade) + } + + /// Exact success toast after `crabcode upgrade` completes. + pub fn updated() -> Self { + Self::new(UPDATED_MESSAGE, ToastLevel::Success, None) + } + + /// Upgrade failure toast (Error level, full message preserved for copy). + pub fn upgrade_failed(message: impl Into) -> Self { + Self::new(message.into(), ToastLevel::Error, None) + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn level(&self) -> ToastLevel { + self.level + } + + pub fn action(&self) -> Option { + self.action + } + fn is_expired(&self, now: Instant) -> bool { self.expires_at <= now } @@ -89,9 +137,31 @@ impl ToastManager { } } - pub fn remove_expired(&mut self) { + /// Drop expired toasts. Returns true when anything was removed so the + /// event loop can schedule a redraw — otherwise the last toast frame + /// stays painted with a dead hitbox (layout already filters expired). + pub fn remove_expired(&mut self) -> bool { let now = Instant::now(); + let before = self.toasts.len(); self.toasts.retain(|toast| !toast.is_expired(now)); + self.toasts.len() != before + } + + /// How long until the next toast expires. `None` when no toasts are + /// queued. The idle event loop caps its blocking `poll()` at this + /// duration so expiry wakes exactly one redraw (no 60fps animation). + pub fn time_until_next_expiry(&self) -> Option { + let now = Instant::now(); + self.toasts + .iter() + .filter_map(|toast| toast.expires_at.checked_duration_since(now)) + .min() + } + + /// True when any toast is currently visible (used to bound idle wakeups). + pub fn has_visible_toasts(&self) -> bool { + let now = Instant::now(); + self.toasts.iter().any(|toast| !toast.is_expired(now)) } pub fn copyable_message_at( @@ -104,6 +174,25 @@ impl ToastManager { .find(|laid| laid.toast.level.is_copyable() && laid.area.contains(position)) .map(|laid| (laid.toast.message.clone(), laid.toast.level)) } + + /// Hit-test for actionable toasts (e.g. the Upgrade nudge). Matches the + /// whole toast area: the entire toast is the Upgrade affordance. + pub fn action_at(&self, frame: Rect, position: Position) -> Option<(ToastAction, String)> { + layout_visible_toasts(frame, self, Instant::now()) + .into_iter() + .find(|laid| laid.toast.action.is_some() && laid.area.contains(position)) + .and_then(|laid| { + laid.toast + .action + .map(|action| (action, laid.toast.message.clone())) + }) + } + + /// Dismiss toasts carrying the given action (used to retire the Upgrade + /// nudge the moment its upgrade starts, preventing double-runs). + pub fn remove_action(&mut self, action: ToastAction) { + self.toasts.retain(|toast| toast.action != Some(action)); + } } struct LaidOutToast<'a> { @@ -426,4 +515,122 @@ mod tests { Some("older error") ); } + + #[test] + fn update_available_toast_has_exact_message_and_upgrade_action() { + let toast = Toast::update_available(); + assert_eq!(toast.message(), "New version available · Upgrade"); + assert_eq!(toast.message(), UPDATE_AVAILABLE_MESSAGE); + assert_eq!(toast.action(), Some(ToastAction::Upgrade)); + assert_eq!(toast.level(), ToastLevel::Info); + } + + #[test] + fn updated_toast_has_exact_success_message() { + let toast = Toast::updated(); + assert_eq!(toast.message(), "Updated · Restart to apply"); + assert_eq!(toast.message(), UPDATED_MESSAGE); + assert_eq!(toast.level(), ToastLevel::Success); + assert_eq!(toast.action(), None); + } + + #[test] + fn upgrade_failed_toast_is_error_level() { + let toast = Toast::upgrade_failed("Update failed: boom"); + assert_eq!(toast.level(), ToastLevel::Error); + assert_eq!(toast.action(), None); + assert!(toast.message().starts_with("Update failed:")); + } + + #[test] + fn clicking_update_toast_returns_upgrade_action() { + let mut manager = ToastManager::new(); + manager.add(Toast::update_available()); + + let laid = layout_visible_toasts(frame(), &manager, Instant::now()); + assert_eq!(laid.len(), 1); + let area = laid[0].area; + + let hit = manager.action_at(frame(), Position::new(area.x, area.y)); + assert!(hit.is_some()); + let (action, message) = hit.unwrap(); + assert_eq!(action, ToastAction::Upgrade); + assert_eq!(message, UPDATE_AVAILABLE_MESSAGE); + } + + #[test] + fn update_toast_is_not_copyable() { + // The Upgrade nudge is Info level, so the error-copy handler ignores it + // and the update-click handler owns the hit area. + let mut manager = ToastManager::new(); + manager.add(Toast::update_available()); + + let laid = layout_visible_toasts(frame(), &manager, Instant::now()); + assert_eq!(laid.len(), 1); + assert!(manager + .copyable_message_at(frame(), Position::new(laid[0].area.x, laid[0].area.y)) + .is_none()); + } + + #[test] + fn click_outside_update_toast_has_no_action() { + let mut manager = ToastManager::new(); + manager.add(Toast::update_available()); + assert!(manager.action_at(frame(), Position::new(0, 0)).is_none()); + } + + #[test] + fn remove_action_dismisses_only_upgrade_toasts() { + let mut manager = ToastManager::new(); + manager.add(Toast::update_available()); + manager.add(long_lived("plain info", ToastLevel::Info)); + + manager.remove_action(ToastAction::Upgrade); + + let laid = layout_visible_toasts(frame(), &manager, Instant::now()); + assert_eq!(laid.len(), 1); + assert_eq!(laid[0].toast.message(), "plain info"); + assert!(manager + .action_at(frame(), Position::new(laid[0].area.x, laid[0].area.y)) + .is_none()); + } + + #[test] + fn remove_expired_reports_whether_redraw_is_needed() { + let mut manager = ToastManager::new(); + assert!(!manager.remove_expired(), "empty queue needs no redraw"); + manager.add(long_lived("fresh", ToastLevel::Info)); + assert!( + !manager.remove_expired(), + "fresh toast must not trigger expiry redraw" + ); + manager.add(Toast::new("gone", ToastLevel::Info, Some(Duration::ZERO))); + // Zero-duration toast is already expired at `Instant::now()`. + assert!( + manager.remove_expired(), + "expired toast must request exactly one redraw" + ); + assert!(!manager.remove_expired(), "second sweep is a no-op"); + } + + #[test] + fn time_until_next_expiry_bounds_idle_wakeup() { + let mut manager = ToastManager::new(); + assert_eq!(manager.time_until_next_expiry(), None); + manager.add(Toast::new( + "short", + ToastLevel::Info, + Some(Duration::from_secs(4)), + )); + let until = manager.time_until_next_expiry().expect("toast pending"); + assert!(until <= Duration::from_secs(4) && !until.is_zero()); + assert!(manager.has_visible_toasts()); + manager.add(Toast::new("gone", ToastLevel::Info, Some(Duration::ZERO))); + // Expired entries are ignored for wakeups (they need an immediate + // redraw instead, via `remove_expired`). + let until2 = manager + .time_until_next_expiry() + .expect("fresh still pending"); + assert!(until2 <= Duration::from_secs(4)); + } } diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 00000000..50400392 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,482 @@ +//! Lazy, nonblocking update check with a 24-hour file cache. +//! +//! The TUI triggers one background check per process after first paint (see +//! `App::maybe_start_update_check`). A fresh cache is read synchronously — no +//! thread, no network — while a stale/missing cache spawns a single blocking +//! lookup. All check failures are silent (`None`); only a strictly newer +//! release produces the ephemeral `New version available · Upgrade` toast. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Successful version lookups stay valid for 24 hours. +pub const UPDATE_CACHE_TTL_SECS: u64 = 24 * 60 * 60; +const UPDATE_CACHE_FILE: &str = "update_check.json"; +const FETCH_TIMEOUT_SECS: &str = "10"; + +/// Opt-out: any `CRABCODE_NO_UPDATE_CHECK` value disables the check. +pub fn update_check_disabled() -> bool { + std::env::var_os("CRABCODE_NO_UPDATE_CHECK").is_some() +} + +/// UI-testing preview: `CRABCODE_FORCE_UPDATE_NOTICE` forces the update toast +/// without cache/network/version checks. Never auto-upgrades; a click still +/// runs the normal upgrade flow. `CRABCODE_NO_UPDATE_CHECK` wins when both set. +pub fn force_update_notice() -> bool { + std::env::var("CRABCODE_FORCE_UPDATE_NOTICE") + .map(|v| is_truthy_flag(&v)) + .unwrap_or(false) +} + +fn is_truthy_flag(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "y" | "on" + ) +} + +/// Effective preview after precedence: force shows unless disabled. +/// Pure helper so the disable-wins rule is unit-testable without env. +pub(crate) fn forced_preview_active(disabled: bool, forced: bool) -> bool { + forced && !disabled +} + +/// Background version-check outcome: `Some(latest)` when a newer release is +/// available, `None` when up to date or the check failed silently. +pub type UpdateCheckResult = Option; + +/// Background upgrade outcome: `Ok(version)` after installers succeed, +/// `Err(message)` with a short human-readable failure. +pub type UpgradeOutcome = Result; + +/// Spawn blocking update/upgrade work on a detached OS thread and deliver the +/// result over the (runtime-independent) unbounded channel. +/// +/// Must not use `tokio::task::spawn_blocking` here: the Tokio runtime waits +/// indefinitely on shutdown for started blocking tasks even when the +/// `JoinHandle` is dropped, so a minutes-long `cargo install`/`brew upgrade` +/// would hang TUI quit. A detached `std` thread is untracked by the runtime, +/// so shutdown stays immediate; the installer child is reparented on TUI exit +/// and reaped via `output()` while attached. If the TUI already exited, the +/// result send just fails silently. No auto-restart, no process kills. +pub(crate) fn spawn_detached_update_worker( + thread_name: &str, + sender: tokio::sync::mpsc::UnboundedSender, + work: impl FnOnce() -> T + Send + 'static, +) { + let _ = std::thread::Builder::new() + .name(thread_name.to_string()) + .spawn(move || { + let out = work(); + let _ = sender.send(out); + }); + // JoinHandle intentionally dropped (detached). Spawn failure drops the + // closure (and its sender), so receivers observe `Disconnected` and clear + // promptly instead of polling forever: silent for the version check, + // "background task ended" toast for the upgrade. +} + +/// Format an upgrade failure for the error toast. +pub fn upgrade_failure_message(err: &str) -> String { + let trimmed = err.trim(); + if trimmed.is_empty() { + return "Update failed: unknown error".to_string(); + } + // Avoid doubling the prefix when the backend already says it. + if trimmed.starts_with("Update failed:") { + trimmed.to_string() + } else { + format!("Update failed: {trimmed}") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct UpdateCache { + checked_at: u64, + latest: String, +} + +pub(crate) fn cache_path() -> PathBuf { + crate::persistence::get_cache_dir().join(UPDATE_CACHE_FILE) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn read_cache_at(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + let cache: UpdateCache = serde_json::from_slice(&bytes).ok()?; + if cache.latest.trim().is_empty() { + return None; + } + Some(cache) +} + +fn write_cache_at(path: &Path, latest: &str, now: u64) { + // Cache writes are best-effort: a failed write just means retrying sooner. + if let Some(parent) = path.parent() { + if std::fs::create_dir_all(parent).is_err() { + return; + } + } + let cache = UpdateCache { + checked_at: now, + latest: latest.to_string(), + }; + let Ok(bytes) = serde_json::to_vec(&cache) else { + return; + }; + // Atomic temp+rename so concurrent TUI processes or a crash mid-write + // can never leave a truncated JSON that forces wasteful refetches. + // Corrupt reads already fall back to refetch (`read_cache_at` => None). + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let temp_name = format!( + ".{}.tmp-{}", + path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("update_check"), + std::process::id() + ); + let temp_path = parent.join(temp_name); + if std::fs::write(&temp_path, &bytes).is_err() { + let _ = std::fs::remove_file(&temp_path); + return; + } + if std::fs::rename(&temp_path, path).is_err() { + let _ = std::fs::remove_file(&temp_path); + } +} + +fn is_fresh_at(checked_at: u64, now: u64) -> bool { + if checked_at > now { + // Future timestamps (clock skew) count as fresh to avoid refetch loops. + return true; + } + now - checked_at < UPDATE_CACHE_TTL_SECS +} + +/// True when `latest` is strictly newer than `current`. +/// +/// Parses both sides as semver after stripping a leading `v` (so `0.0.13` +/// beats `0.0.12`, while equal or older releases stay quiet). Tags that are +/// not semver fall back to inequality — the same eligibility `crabcode +/// upgrade` uses — so unusual tags still surface instead of vanishing. +pub fn is_newer_version(current: &str, latest: &str) -> bool { + let current = current.trim().trim_start_matches('v'); + let latest = latest.trim().trim_start_matches('v'); + match ( + semver::Version::parse(current), + semver::Version::parse(latest), + ) { + (Ok(current), Ok(latest)) => latest > current, + _ => !current.eq_ignore_ascii_case(latest), + } +} + +/// Toast eligibility: show the nudge only for strictly newer releases. +pub fn is_update_available(current: &str, latest: &str) -> bool { + is_newer_version(current, latest) +} + +/// Fresh cached release newer than `current`, without touching the network. +pub(crate) fn load_cached_update(current: &str) -> Option { + load_cached_update_at(&cache_path(), current, now_secs()) +} + +fn load_cached_update_at(path: &Path, current: &str, now: u64) -> Option { + let cache = read_cache_at(path)?; + if !is_fresh_at(cache.checked_at, now) { + return None; + } + is_update_available(current, &cache.latest).then(|| cache.latest) +} + +/// Whether the cache is missing/stale and a background fetch is warranted. +pub(crate) fn should_fetch_update() -> bool { + should_fetch_update_at(&cache_path(), now_secs()) +} + +fn should_fetch_update_at(path: &Path, now: u64) -> bool { + match read_cache_at(path) { + None => true, + Some(cache) => !is_fresh_at(cache.checked_at, now), + } +} + +/// Blocking check for the background thread: fresh cache first, else one +/// GitHub lookup. Successes refresh the cache; failures stay silent (`None`). +pub(crate) fn check_and_cache(current: &str) -> UpdateCheckResult { + let path = cache_path(); + let now = now_secs(); + if let Some(latest) = load_cached_update_at(&path, current, now) { + return Some(latest); + } + if !should_fetch_update_at(&path, now) { + // Fresh cache that is already current — respect the 24h TTL. + return None; + } + let latest = match fetch_latest_tag_blocking() { + Ok(tag) => tag, + Err(_) => return None, + }; + write_cache_at(&path, &latest, now_secs()); + is_update_available(current, &latest).then_some(latest) +} + +#[derive(Debug, Deserialize)] +struct GithubRelease { + tag_name: String, +} + +fn fetch_latest_tag_blocking() -> Result { + let url = format!( + "https://api.github.com/repos/{}/releases/latest", + crate::upgrade::GITHUB_REPO + ); + let output = std::process::Command::new("curl") + .args([ + "-fsSL", + "--max-time", + FETCH_TIMEOUT_SECS, + "-H", + "Accept: application/vnd.github+json", + "-H", + "User-Agent: crabcode-update-check", + &url, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .context("failed to run curl")?; + + if !output.status.success() { + anyhow::bail!("GitHub release lookup failed with {}", output.status); + } + + let release: GithubRelease = + serde_json::from_slice(&output.stdout).context("failed to parse release")?; + Ok(release.tag_name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newer_patch_triggers_toast() { + assert!(is_update_available("0.0.12", "0.0.13")); + assert!(is_update_available("v0.0.12", "v0.0.13")); + assert!(is_update_available("0.0.12", "v0.0.13")); + } + + #[test] + fn equal_versions_stay_quiet() { + assert!(!is_update_available("0.0.12", "0.0.12")); + assert!(!is_update_available("v0.0.12", "0.0.12")); + assert!(!is_update_available(" 0.0.12 ", "v0.0.12")); + } + + #[test] + fn older_releases_stay_quiet() { + // A dev build ahead of the latest release must not toast. + assert!(!is_update_available("0.0.13", "0.0.12")); + assert!(!is_update_available("0.1.0", "0.0.99")); + assert!(!is_update_available("1.0.0", "0.9.9")); + } + + #[test] + fn prerelease_counts_as_older_than_release() { + assert!(is_update_available("0.0.13-dev", "0.0.13")); + assert!(!is_update_available("0.0.13", "0.0.13-dev")); + } + + #[test] + fn non_semver_tags_fall_back_to_inequality() { + assert!(!is_update_available("abc", "abc")); + assert!(is_update_available("abc", "def")); + } + + #[test] + fn fresh_cache_counts_but_stale_does_not() { + let now = 1_700_000_000; + assert!(is_fresh_at(now - 60, now)); + assert!(is_fresh_at(now - (UPDATE_CACHE_TTL_SECS - 1), now)); + assert!(!is_fresh_at(now - UPDATE_CACHE_TTL_SECS, now)); + assert!(!is_fresh_at(now - (UPDATE_CACHE_TTL_SECS + 1), now)); + assert!(!is_fresh_at(0, now)); + } + + #[test] + fn future_cache_timestamps_count_as_fresh() { + let now = 1_700_000_000; + assert!(is_fresh_at(now + 60, now)); + } + + #[test] + fn missing_or_corrupt_cache_forces_fetch() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("update_check.json"); + assert!(should_fetch_update_at(&missing, 1_700_000_000)); + assert!(read_cache_at(&missing).is_none()); + + std::fs::write(&missing, b"not json").unwrap(); + assert!(should_fetch_update_at(&missing, 1_700_000_000)); + assert!(read_cache_at(&missing).is_none()); + + std::fs::write(&missing, r#"{"checked_at":1,"latest":""}"#).unwrap(); + assert!(read_cache_at(&missing).is_none()); + } + + #[test] + fn cache_roundtrip_and_freshness() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update_check.json"); + let now = 1_700_000_000; + write_cache_at(&path, "0.0.13", now); + + let cache = read_cache_at(&path).unwrap(); + assert_eq!( + cache, + UpdateCache { + checked_at: now, + latest: "0.0.13".to_string(), + } + ); + assert!(!should_fetch_update_at(&path, now)); + assert!(should_fetch_update_at(&path, now + UPDATE_CACHE_TTL_SECS)); + } + + #[test] + fn cached_update_only_when_fresh_and_newer() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("update_check.json"); + let now = 1_700_000_000; + + // Fresh + newer → toast eligible. + write_cache_at(&path, "0.0.13", now); + assert_eq!( + load_cached_update_at(&path, "0.0.12", now), + Some("0.0.13".to_string()) + ); + // Fresh + equal → quiet. + assert_eq!(load_cached_update_at(&path, "0.0.13", now), None); + // Fresh + older → quiet (ahead of release). + assert_eq!(load_cached_update_at(&path, "0.0.14", now), None); + // Stale even when newer → no toast without a refetch. + assert_eq!( + load_cached_update_at(&path, "0.0.12", now + UPDATE_CACHE_TTL_SECS), + None + ); + } + + #[test] + fn force_flag_truthy_values() { + for value in [ + "1", "true", "TRUE", "True", "yes", "YES", "y", "Y", "on", "ON", " 1 ", + ] { + assert!(is_truthy_flag(value), "{value:?} should be truthy"); + } + } + + #[test] + fn force_flag_falsy_values() { + for value in ["", " ", "0", "false", "no", "off", "2", "maybe"] { + assert!(!is_truthy_flag(value), "{value:?} should be falsy"); + } + } + + #[test] + fn disable_wins_over_force_for_preview() { + assert!(forced_preview_active(false, true)); + // Disable wins: forced toast stays off when opted out. + assert!(!forced_preview_active(true, true)); + assert!(!forced_preview_active(false, false)); + assert!(!forced_preview_active(true, false)); + } + + #[test] + fn upgrade_failure_message_adds_prefix_once() { + assert_eq!(upgrade_failure_message("boom"), "Update failed: boom"); + assert_eq!( + upgrade_failure_message("Update failed: boom"), + "Update failed: boom" + ); + assert_eq!( + upgrade_failure_message(" "), + "Update failed: unknown error" + ); + assert_eq!(upgrade_failure_message(""), "Update failed: unknown error"); + } + + #[test] + fn detached_worker_does_not_block_tokio_shutdown() { + use std::time::{Duration, Instant}; + + // Same helper the TUI upgrade/check paths use, but with fake parked + // work instead of real installers or network probes. + let (gate_tx, gate_rx) = std::sync::mpsc::channel::<()>(); + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("test runtime"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + + rt.block_on(async { + spawn_detached_update_worker("crabcode-test-detached", tx, move || { + // Park without touching installers/network. `recv_timeout` + // bounds the failure mode: if a refactor accidentally moves + // this back to `spawn_blocking`, shutdown blocks ~10s then + // fails instead of deadlocking the suite forever. + let _ = gate_rx.recv_timeout(Duration::from_secs(10)); + 42 + }); + // Let the worker start and park before shutting down. + tokio::task::yield_now().await; + tokio::task::yield_now().await; + }); + + // Dropping the runtime must not wait for the parked detached worker. + // `spawn_blocking` would wait indefinitely here (docs.rs: shutdown + // waits indefinitely for started blocking tasks even if the handle is + // dropped); a detached `std` thread is untracked so this is immediate. + let start = Instant::now(); + drop(rt); + let elapsed = start.elapsed(); + assert!( + elapsed < Duration::from_secs(5), + "detached update worker blocked Tokio shutdown for {elapsed:?}; \ + upgrade/check must use detached std threads, not spawn_blocking" + ); + + // Release the parked worker and prove the result still arrives without + // a running runtime (unbounded send is runtime-independent). + let _ = gate_tx.send(()); + let deadline = Instant::now() + Duration::from_secs(2); + let got = loop { + match rx.try_recv() { + Ok(v) => break Some(v), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { + if Instant::now() >= deadline { + break None; + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break None, + } + }; + assert_eq!( + got, + Some(42), + "detached worker must still deliver via channel" + ); + } +} diff --git a/src/upgrade.rs b/src/upgrade.rs index a696b164..934269d3 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -7,7 +7,7 @@ use std::process::{Command, Stdio}; use anyhow::{bail, Context, Result}; use serde::Deserialize; -const GITHUB_REPO: &str = "Blankeos/crabcode"; +pub(crate) const GITHUB_REPO: &str = "Blankeos/crabcode"; const BREW_FORMULA: &str = "blankeos/tap/crabcode"; const NPM_PACKAGE: &str = "crabcode"; const BINARY_NAME: &str = "crabcode"; @@ -87,6 +87,27 @@ struct VersionCheck { needs_upgrade: bool, } +/// Current binary version (`CARGO_PKG_VERSION`). +pub(crate) fn current_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +/// Upgrade without touching the caller's terminal: stdin is `/dev/null` (so a +/// missing tool fails fast instead of prompting invisibly under the TUI), +/// stdout/stderr are captured (so package-manager output never redraws over +/// the active terminal), and helpers get no-input env. Returns the target +/// version. Errors carry the failing command plus the tail of its output. +pub(crate) fn upgrade_noninteractive(target: Option<&str>) -> Result { + let current = current_version(); + let method = detect_install_method()?; + let check = resolve_target_version(¤t, target)?; + if !check.needs_upgrade { + return Ok(check.target); + } + run_method_upgrade_captured(&method, &check.target)?; + Ok(check.target) +} + /// Upgrade crabcode to the latest release, or to a specific target version. pub fn upgrade(target: Option<&str>) -> Result<()> { let current = env!("CARGO_PKG_VERSION").to_string(); @@ -146,17 +167,26 @@ fn display_version(version: &str) -> String { format!("v{}", normalize_version(version)) } +/// Bounded GitHub lookup for the upgrade target (mirrors the 10s update +/// check). Null stdin + no-prompt env so a background upgrade thread can +/// never hang forever waiting on a prompt; `--max-time` bounds the network. fn fetch_latest_tag() -> Result { let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest"); let output = Command::new("curl") .args([ "-fsSL", + "--max-time", + "10", "-H", "Accept: application/vnd.github+json", "-H", "User-Agent: crabcode-upgrade", &url, ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("GIT_TERMINAL_PROMPT", "0") .output() .context("failed to run curl (is it installed?)")?; @@ -219,6 +249,32 @@ fn detect_install_method() -> Result { } fn detect_install_method_from_path(path: &Path) -> InstallMethod { + // Gather expensive ownership signals lazily: only the cargo-bin branch + // needs receipt/cargo claims, and only ambiguous paths need brew/cargo/js + // probes. Pure helpers below keep this unit-testable without spawning. + detect_install_method_from_path_impl( + path, + &cargo_bin_dir(), + || load_shell_install_receipt(), + || cargo_install_list_has(BINARY_NAME), + || brew_owns_formula(BINARY_NAME), + || detect_js_manager_strict(), + || js_global_has(NPM_PACKAGE), + command_exists("cargo-binstall"), + ) +} + +#[allow(clippy::too_many_arguments)] +fn detect_install_method_from_path_impl( + path: &Path, + cargo_bin: &Path, + load_receipt: impl FnOnce() -> Option, + cargo_claims: impl FnOnce() -> bool, + brew_claims: impl FnOnce() -> bool, + js_owner: impl FnOnce() -> Option, + js_any_claims: impl FnOnce() -> bool, + use_binstall: bool, +) -> InstallMethod { let path_str = path.to_string_lossy(); // Homebrew Cellar / opt paths (symlink targets usually land under Cellar) @@ -234,18 +290,33 @@ fn detect_install_method_from_path(path: &Path) -> InstallMethod { // JS package managers (npm/bun/pnpm/yarn global installs live under node_modules) if path_str.contains("node_modules") { - let manager = detect_js_manager_from_path(&path_str); - return InstallMethod::Js { manager }; + // Path markers for bun/pnpm/yarn are authoritative; a generic + // node_modules path (fnm/nvm/npm) must prove single-owner via the + // managers' global lists, otherwise fail safe to Unknown instead of + // guessing an available-but-unrelated manager. + match detect_js_manager_from_path_strict(&path_str, js_owner) { + Some(manager) => return InstallMethod::Js { manager }, + None => { + return InstallMethod::Unknown { + path: path.to_path_buf(), + } + } + } } - // cargo install / cargo binstall - if let Some(home) = home_dir() { - let cargo_bin = home.join(".cargo").join("bin"); - if path.starts_with(&cargo_bin) { - return InstallMethod::Cargo { - use_binstall: command_exists("cargo-binstall"), - }; - } + // cargo install / cargo binstall vs cargo-dist shell installer. + // + // Both default to `$CARGO_HOME/bin` (or `~/.cargo/bin` when `CARGO_HOME` + // is unset), so a path prefix alone cannot tell them apart. Compare the + // shell install receipt against cargo's install metadata: + // - receipt claims + cargo silent => shell installer (InstallScript) + // - cargo claims + no receipt => cargo install + // - neither or both => Unknown (fail safe, never guess) + if path.starts_with(cargo_bin) { + let receipt = load_receipt(); + let receipt_claims = receipt.as_ref().is_some_and(|r| r.claims_path(path)); + let cargo_owns = cargo_claims(); + return classify_cargo_bin_path(path, receipt_claims, cargo_owns, use_binstall); } // install.sh default destination @@ -254,17 +325,23 @@ fn detect_install_method_from_path(path: &Path) -> InstallMethod { } // Heuristics when path alone is ambiguous - if brew_owns_formula(BINARY_NAME) { + if brew_claims() { return InstallMethod::Homebrew; } - if cargo_install_list_has(BINARY_NAME) { - return InstallMethod::Cargo { - use_binstall: command_exists("cargo-binstall"), - }; + if cargo_claims() { + return InstallMethod::Cargo { use_binstall }; } - if js_global_has(NPM_PACKAGE) { - let manager = detect_js_manager_available(); - return InstallMethod::Js { manager }; + if js_any_claims() { + // Only upgrade via the owning manager; ambiguous (multi-owner) or + // unowned-but-listed states fail safe to Unknown. + match js_owner() { + Some(manager) => return InstallMethod::Js { manager }, + None => { + return InstallMethod::Unknown { + path: path.to_path_buf(), + } + } + } } InstallMethod::Unknown { @@ -272,32 +349,65 @@ fn detect_install_method_from_path(path: &Path) -> InstallMethod { } } -fn detect_js_manager_from_path(path_str: &str) -> JsPackageManager { +/// Pure cargo-bin disambiguation so tests never spawn `cargo`/receipt I/O. +fn classify_cargo_bin_path( + path: &Path, + receipt_claims: bool, + cargo_claims: bool, + use_binstall: bool, +) -> InstallMethod { + match (receipt_claims, cargo_claims) { + (true, false) => InstallMethod::InstallScript, + (false, true) => InstallMethod::Cargo { use_binstall }, + // Neither signal (unmanaged copy?) or both (double install) must + // never guess: surface the reinstall help instead of running the + // wrong updater against the same destination. + _ => InstallMethod::Unknown { + path: path.to_path_buf(), + }, + } +} + +/// Strict JS manager from a node_modules path: bun/pnpm/yarn markers are +/// authoritative; generic paths require exactly one global-list owner. +fn detect_js_manager_from_path_strict( + path_str: &str, + js_owner: impl FnOnce() -> Option, +) -> Option { if path_str.contains("/.bun/") || path_str.contains("\\.bun\\") { - JsPackageManager::Bun + Some(JsPackageManager::Bun) } else if path_str.contains("pnpm") { - JsPackageManager::Pnpm + Some(JsPackageManager::Pnpm) } else if path_str.contains("yarn") { - JsPackageManager::Yarn + Some(JsPackageManager::Yarn) } else { - detect_js_manager_available() + js_owner() } } -fn detect_js_manager_available() -> JsPackageManager { - if command_exists("bun") && js_global_has_with("bun", NPM_PACKAGE) { - return JsPackageManager::Bun; +/// Owning JS manager or `None` when zero/multi owners (fail safe). +/// Never falls back to a merely-available manager: upgrading via a +/// non-owning manager would write a different global prefix and leave the +/// real install stale. +fn detect_js_manager_strict() -> Option { + let mut owners = Vec::new(); + if js_global_has_with("npm", NPM_PACKAGE) { + owners.push(JsPackageManager::Npm); } - if command_exists("pnpm") && js_global_has_with("pnpm", NPM_PACKAGE) { - return JsPackageManager::Pnpm; + if js_global_has_with("bun", NPM_PACKAGE) { + owners.push(JsPackageManager::Bun); } - if command_exists("yarn") { - return JsPackageManager::Yarn; + if js_global_has_with("pnpm", NPM_PACKAGE) { + owners.push(JsPackageManager::Pnpm); } - if command_exists("bun") { - return JsPackageManager::Bun; + if js_global_has_with("yarn", NPM_PACKAGE) { + owners.push(JsPackageManager::Yarn); + } + if owners.len() == 1 { + owners.into_iter().next() + } else { + None } - JsPackageManager::Npm } fn run_method_upgrade(method: &InstallMethod, target_version: &str) -> Result<()> { @@ -307,19 +417,223 @@ fn run_method_upgrade(method: &InstallMethod, target_version: &str) -> Result<() InstallMethod::Cargo { use_binstall } => upgrade_cargo(*use_binstall, target_version), InstallMethod::InstallScript => upgrade_install_script(target_version), InstallMethod::Unknown { path } => { - bail!( - "could not determine install method for `{}`.\n\ - Reinstall with one of:\n\ - • brew install {BREW_FORMULA}\n\ - • npm install -g {NPM_PACKAGE}\n\ - • cargo binstall {BINARY_NAME}\n\ - • curl --proto '=https' --tlsv1.2 -LsSf https://github.com/{GITHUB_REPO}/releases/latest/download/{BINARY_NAME}-installer.sh | sh", - path.display() - ) + bail!("{}", unknown_method_help(path)); + } + } +} + +fn unknown_method_help(path: &Path) -> String { + format!( + "could not determine install method for `{}`.\n\ + Reinstall with one of:\n\ + • brew install {BREW_FORMULA}\n\ + • npm install -g {NPM_PACKAGE}\n\ + • cargo binstall {BINARY_NAME}\n\ + • curl --proto '=https' --tlsv1.2 -LsSf https://github.com/{GITHUB_REPO}/releases/latest/download/{BINARY_NAME}-installer.sh | sh", + path.display() + ) +} + +/// Captured mirror of [`run_method_upgrade`] for TUI use: nothing inherits the +/// terminal, nothing prints, nothing can prompt. +fn run_method_upgrade_captured(method: &InstallMethod, target_version: &str) -> Result<()> { + match method { + InstallMethod::Homebrew => upgrade_brew_captured(target_version), + InstallMethod::Js { manager } => upgrade_js_captured(manager, target_version), + InstallMethod::Cargo { use_binstall } => { + upgrade_cargo_captured(*use_binstall, target_version) + } + InstallMethod::InstallScript => upgrade_install_script_captured(target_version), + InstallMethod::Unknown { path } => { + bail!("{}", unknown_method_help(path)); } } } +fn upgrade_brew_captured(target_version: &str) -> Result<()> { + if !command_exists("brew") { + bail!("detected Homebrew install, but `brew` is not on PATH"); + } + + // Third-party formulae typically only track latest; specific versions aren't pin-installable. + let _ = target_version; + run_command_captured("brew", &["upgrade", BREW_FORMULA]) +} + +fn upgrade_js_captured(manager: &JsPackageManager, target_version: &str) -> Result<()> { + let spec = format!("{NPM_PACKAGE}@{target_version}"); + let (bin, args) = manager.install_global_cmd(&spec); + if !command_exists(&bin) { + bail!( + "detected `{}` install, but `{bin}` is not on PATH", + manager.as_str() + ); + } + run_command_captured(&bin, &args.iter().map(String::as_str).collect::>()) +} + +fn upgrade_cargo_captured(use_binstall: bool, target_version: &str) -> Result<()> { + if use_binstall && command_exists("cargo-binstall") { + let args = [ + "binstall".to_string(), + "-y".to_string(), + format!("{BINARY_NAME}@{target_version}"), + ]; + return run_command_captured( + "cargo", + &args.iter().map(String::as_str).collect::>(), + ); + } + + if !command_exists("cargo") { + bail!("detected cargo install, but `cargo` is not on PATH"); + } + + let args = [ + "install".to_string(), + BINARY_NAME.to_string(), + "--locked".to_string(), + "--force".to_string(), + "--version".to_string(), + target_version.to_string(), + ]; + run_command_captured( + "cargo", + &args.iter().map(String::as_str).collect::>(), + ) +} + +fn upgrade_install_script_captured(target_version: &str) -> Result<()> { + #[cfg(windows)] + { + let _ = target_version; + bail!( + "automatic upgrades via install script are not supported on Windows; \ + reinstall with npm, cargo, or the latest GitHub release" + ); + } + + #[cfg(not(windows))] + { + let tag = format!("v{}", normalize_version(target_version)); + let url = format!( + "https://github.com/{GITHUB_REPO}/releases/download/{tag}/{BINARY_NAME}-installer.sh" + ); + let latest_url = format!( + "https://github.com/{GITHUB_REPO}/releases/latest/download/{BINARY_NAME}-installer.sh" + ); + + let installer_bytes = download_bytes_captured(&url) + .filter(|bytes| !bytes.is_empty()) + .or_else(|| download_bytes_captured(&latest_url).filter(|bytes| !bytes.is_empty())) + .context("failed to download installer")?; + + // Secure temp-file execution (no stdin-pipe deadlock): + // the old code wrote the whole script to the child's stdin while + // stdout/stderr pipes were undrained — a large script plus verbose + // installer output fills both 64 KiB pipe buffers and deadlocks + // (parent blocked on stdin write, child blocked on stdout write). + // Executing a temp file with null stdin drains stdout/stderr via + // `output()` (no pipe stall), fails fast on prompts (EOF), and the + // `NamedTempFile` deletes on drop even on failure while `output()` + // reaps the child (no zombie, no unrelated-process kill). + use std::io::Write; + let mut script_file = + tempfile::NamedTempFile::with_suffix(".sh").context("failed to stage installer")?; + script_file + .write_all(&installer_bytes) + .context("failed to stage installer")?; + script_file.flush().context("failed to stage installer")?; + + let mut cmd = Command::new("sh"); + cmd.arg(script_file.path()) + .arg(&tag) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("NONINTERACTIVE", "1") + .env("GIT_TERMINAL_PROMPT", "0"); + // Pin the reinstall to the original receipt prefix so an upgrade + // overwrites the same destination even if CARGO_HOME drifted since + // install. Both the per-app var and the generic dist override are + // set; unknown installer versions ignore unknown env harmlessly. + if let Some(prefix) = load_shell_install_receipt().and_then(|r| r.reinstall_prefix()) { + cmd.env("CRABCODE_INSTALL_DIR", &prefix); + cmd.env("CARGO_DIST_FORCE_INSTALL_DIR", &prefix); + } + let output = cmd.output().context("installer failed to run")?; + // `script_file` deletes here on all paths (success/failure/panic + // unwind via drop); `output()` already waited+reaped the child. + if !output.status.success() { + let tail = tail_text(&output.stderr, MAX_ERROR_TAIL_CHARS); + bail!("installer exited with {}\n{tail}", output.status); + } + Ok(()) + } +} + +fn download_bytes_captured(url: &str) -> Option> { + let output = Command::new("curl") + .args(["-fsSL", "--max-time", "25", url]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .ok()?; + if !output.status.success() || output.stdout.is_empty() { + return None; + } + Some(output.stdout) +} + +/// Max trailing chars of captured stderr kept in upgrade errors (the toast +/// truncates display anyway, but the full message stays copyable). +const MAX_ERROR_TAIL_CHARS: usize = 800; + +/// Keep the tail of command output for errors, on char boundaries. +fn tail_text(bytes: &[u8], max_chars: usize) -> String { + let text = String::from_utf8_lossy(bytes); + let trimmed = text.trim(); + if trimmed.chars().count() <= max_chars { + return trimmed.to_string(); + } + trimmed + .chars() + .rev() + .take(max_chars) + .collect::>() + .into_iter() + .rev() + .collect() +} + +/// Run a package-manager upgrade with no terminal attachment: stdin is null +/// (prompts get EOF and fail fast), output is captured, and helpers are told +/// not to prompt. +fn run_command_captured(program: &str, args: &[&str]) -> Result<()> { + let output = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("HOMEBREW_NO_INPUT", "1") + .env("NONINTERACTIVE", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .with_context(|| format!("failed to run `{program}`"))?; + + if !output.status.success() { + let tail = tail_text(&output.stderr, MAX_ERROR_TAIL_CHARS); + bail!( + "`{program} {}` failed with {}\n{tail}", + args.join(" "), + output.status + ); + } + Ok(()) +} + fn upgrade_brew(target_version: &str) -> Result<()> { if !command_exists("brew") { bail!("detected Homebrew install, but `brew` is not on PATH"); @@ -401,7 +715,10 @@ fn upgrade_install_script(target_version: &str) -> Result<()> { println!("→ Doing `curl ... | sh -s -- {tag}`"); let script = Command::new("curl") - .args(["-fsSL", &url]) + .args(["-fsSL", "--max-time", "25", &url]) + .stdin(Stdio::null()) + .stderr(Stdio::piped()) + .env("GIT_TERMINAL_PROMPT", "0") .output() .context("failed to download installer")?; @@ -409,7 +726,10 @@ fn upgrade_install_script(target_version: &str) -> Result<()> { script.stdout } else { let fallback = Command::new("curl") - .args(["-fsSL", &latest_url]) + .args(["-fsSL", "--max-time", "25", &latest_url]) + .stdin(Stdio::null()) + .stderr(Stdio::piped()) + .env("GIT_TERMINAL_PROMPT", "0") .output() .context("failed to download installer")?; if !fallback.status.success() { @@ -467,6 +787,7 @@ fn command_exists(name: &str) -> bool { { Command::new("which") .arg(name) + .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() @@ -477,6 +798,7 @@ fn command_exists(name: &str) -> bool { { Command::new("where") .arg(name) + .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() @@ -491,8 +813,11 @@ fn brew_owns_formula(name: &str) -> bool { } Command::new("brew") .args(["list", "--formula", name]) + .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) + .env("HOMEBREW_NO_INPUT", "1") + .env("NONINTERACTIVE", "1") .status() .map(|s| s.success()) .unwrap_or(false) @@ -504,8 +829,10 @@ fn cargo_install_list_has(name: &str) -> bool { } let output = Command::new("cargo") .args(["install", "--list"]) + .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) + .env("GIT_TERMINAL_PROMPT", "0") .output(); match output { Ok(out) if out.status.success() => { @@ -522,6 +849,7 @@ fn js_global_has(package: &str) -> bool { js_global_has_with("npm", package) || js_global_has_with("bun", package) || js_global_has_with("pnpm", package) + || js_global_has_with("yarn", package) } fn js_global_has_with(manager: &str, package: &str) -> bool { @@ -533,16 +861,32 @@ fn js_global_has_with(manager: &str, package: &str) -> bool { .args(["list", "-g", "--depth=0", package]) .stdout(Stdio::piped()) .stderr(Stdio::null()) + .stdin(Stdio::null()) + .env("GIT_TERMINAL_PROMPT", "0") .output(), "bun" => Command::new("bun") .args(["pm", "ls", "-g"]) .stdout(Stdio::piped()) .stderr(Stdio::null()) + .stdin(Stdio::null()) + .env("GIT_TERMINAL_PROMPT", "0") .output(), "pnpm" => Command::new("pnpm") .args(["list", "-g", "--depth=0", package]) .stdout(Stdio::piped()) .stderr(Stdio::null()) + .stdin(Stdio::null()) + .env("GIT_TERMINAL_PROMPT", "0") + .output(), + // Yarn classic lists globals via `yarn global list`; Berry has no + // stable global-list. A failed/unknown layout means "not proven + // owner" (false) so we fail safe to Unknown rather than guessing. + "yarn" => Command::new("yarn") + .args(["global", "list", "--depth=0"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .stdin(Stdio::null()) + .env("GIT_TERMINAL_PROMPT", "0") .output(), _ => return false, }; @@ -561,6 +905,180 @@ fn home_dir() -> Option { .map(PathBuf::from) } +/// Cargo home respecting `CARGO_HOME` (custom installs) with `~/.cargo` +/// fallback. Both `cargo install` and the cargo-dist shell installer default +/// to `$CARGO_HOME/bin`, so detection and receipt checks must use this — not +/// a hardcoded `~/.cargo/bin`. +fn cargo_home() -> PathBuf { + if let Some(dir) = env::var_os("CARGO_HOME") { + if !dir.is_empty() { + return PathBuf::from(dir); + } + } + home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".cargo") +} + +fn cargo_bin_dir() -> PathBuf { + cargo_home().join("bin") +} + +/// cargo-dist shell-install receipt (written unless `*_UNMANAGED_INSTALL`). +/// +/// Shell installer writes `$HOME/.config/{app}/{app}-receipt.json` +/// (dist ≥0.27 also respects `XDG_CONFIG_HOME`). The receipt records the +/// install prefix/layout/binaries so an updater can tell a shell install in +/// `$CARGO_HOME/bin` apart from a `cargo install` in the same directory. +#[derive(Debug, Clone, Deserialize, Default)] +struct InstallReceipt { + #[serde(default)] + binaries: Vec, + #[serde(default)] + binary_aliases: std::collections::HashMap>, + #[serde(default)] + install_prefix: String, + #[serde(default)] + install_layout: String, +} + +impl InstallReceipt { + fn owns_binary(&self, binary: &str) -> bool { + self.binaries.iter().any(|b| b == binary) + || self.binary_aliases.keys().any(|k| k == binary) + || self + .binary_aliases + .values() + .flatten() + .any(|alias| alias == binary) + } + + /// True when this receipt pins `binary_path` as its install destination. + fn claims_path(&self, binary_path: &Path) -> bool { + if !self.owns_binary(BINARY_NAME) { + return false; + } + let prefix = expand_receipt_prefix(&self.install_prefix); + if prefix.as_os_str().is_empty() { + return false; + } + // cargo-home layout installs to `/bin`; flat layouts install + // directly under prefix. `starts_with(prefix)` covers both without + // guessing layout strings. + binary_path.starts_with(&prefix) + } + + /// Prefix to force on reinstall so the upgrade overwrites the original + /// destination even if `CARGO_HOME` changed since install. + fn reinstall_prefix(&self) -> Option { + let prefix = self.install_prefix.trim(); + if prefix.is_empty() || !self.owns_binary(BINARY_NAME) { + return None; + } + Some(prefix.to_string()) + } +} + +fn expand_receipt_prefix(raw: &str) -> PathBuf { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return PathBuf::new(); + } + // Receipts are absolute paths, but expand common placeholders defensively. + if let Some(rest) = trimmed + .strip_prefix("$CARGO_HOME") + .or_else(|| trimmed.strip_prefix("${CARGO_HOME}")) + { + let rest = rest.trim_start_matches('/').trim_start_matches('\\'); + return cargo_home().join(rest); + } + if let Some(rest) = trimmed + .strip_prefix("$HOME") + .or_else(|| trimmed.strip_prefix("${HOME}")) + { + let rest = rest.trim_start_matches('/').trim_start_matches('\\'); + if let Some(home) = home_dir() { + return home.join(rest); + } + return PathBuf::from(rest); + } + if let Some(rest) = trimmed.strip_prefix('~') { + let rest = rest.trim_start_matches('/').trim_start_matches('\\'); + if let Some(home) = home_dir() { + return home.join(rest); + } + return PathBuf::from(rest); + } + PathBuf::from(trimmed) +} + +fn shell_receipt_dirs() -> Vec { + let mut dirs = Vec::new(); + // dist ≥0.27 respects XDG_CONFIG_HOME; older installers used HOME/.config. + // Check both (deduplicated) so upgrades work regardless of installer age. + if let Some(xdg) = env::var_os("XDG_CONFIG_HOME") { + if !xdg.is_empty() { + let dir = PathBuf::from(xdg).join("crabcode"); + if !dirs.contains(&dir) { + dirs.push(dir); + } + } + } + if let Some(home) = home_dir() { + let dir = home.join(".config").join("crabcode"); + if !dirs.contains(&dir) { + dirs.push(dir); + } + } + // Windows PowerShell installer receipt location. + #[cfg(windows)] + { + if let Some(local) = env::var_os("LOCALAPPDATA") { + if !local.is_empty() { + let dir = PathBuf::from(local).join("crabcode"); + if !dirs.contains(&dir) { + dirs.push(dir); + } + } + } + } + dirs +} + +fn load_shell_install_receipt() -> Option { + let dirs = shell_receipt_dirs(); + // Preferred exact receipt name first (`{app}-receipt.json`). + for dir in &dirs { + let candidate = dir.join(format!("{BINARY_NAME}-receipt.json")); + if let Ok(bytes) = std::fs::read(&candidate) { + if let Ok(receipt) = serde_json::from_slice::(&bytes) { + if receipt.owns_binary(BINARY_NAME) { + return Some(receipt); + } + } + } + } + // Fallback: scan receipt dirs for any JSON receipt owning our binary + // (tolerates future installer renames without misattributing others). + for dir in &dirs { + let entries = std::fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "json") { + continue; + } + if let Ok(bytes) = std::fs::read(&path) { + if let Ok(receipt) = serde_json::from_slice::(&bytes) { + if receipt.owns_binary(BINARY_NAME) { + return Some(receipt); + } + } + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -571,20 +1089,96 @@ mod tests { assert_eq!(normalize_version("1.2.3"), "1.2.3"); } + /// Hermetic detection helper: injects ownership signals instead of + /// spawning `cargo`/`brew`/`npm` (no test-env races, no PATH dependence). + fn detect_with( + path: &Path, + cargo_bin: &Path, + receipt: Option, + cargo_claims: bool, + brew_claims: bool, + js_owner: Option, + js_any: bool, + ) -> InstallMethod { + let receipt_clone = receipt.clone(); + let js_owner_clone = js_owner.clone(); + detect_install_method_from_path_impl( + path, + cargo_bin, + move || receipt_clone.clone(), + move || cargo_claims, + move || brew_claims, + move || js_owner_clone.clone(), + move || js_any, + false, + ) + } + + fn fake_cargo_bin() -> PathBuf { + PathBuf::from("/Users/me/.cargo/bin") + } + + fn shell_receipt_for(prefix: &str) -> InstallReceipt { + InstallReceipt { + binaries: vec![BINARY_NAME.to_string()], + binary_aliases: Default::default(), + install_prefix: prefix.to_string(), + install_layout: "cargo-home".to_string(), + } + } + #[test] - fn detects_npm_node_modules_path() { + fn detects_npm_node_modules_path_with_single_owner() { let path = PathBuf::from( "/Users/me/.local/share/fnm/node-versions/v22/installation/lib/node_modules/crabcode/bin/crabcode", ); - let method = detect_install_method_from_path(&path); - assert!(matches!(method, InstallMethod::Js { .. }), "{method:?}"); + // Generic node_modules + exactly one global-list owner => that manager. + let method = detect_with( + &path, + &fake_cargo_bin(), + None, + false, + false, + Some(JsPackageManager::Npm), + true, + ); + assert!( + matches!( + method, + InstallMethod::Js { + manager: JsPackageManager::Npm + } + ), + "{method:?}" + ); } #[test] - fn detects_bun_path() { + fn generic_node_modules_without_single_owner_fails_safe() { + let path = PathBuf::from( + "/Users/me/.local/share/fnm/node-versions/v22/installation/lib/node_modules/crabcode/bin/crabcode", + ); + // No owner (fresh env) => Unknown, never guess an available manager. + let none = detect_with(&path, &fake_cargo_bin(), None, false, false, None, false); + assert!(matches!(none, InstallMethod::Unknown { .. }), "{none:?}"); + // Multi-owner (npm+bun both list it) => Unknown (ambiguous). + // `detect_with` takes a single resolved owner; `None` models the + // strict resolver's ambiguous output (js_any true but no single owner). + let ambiguous = detect_with(&path, &fake_cargo_bin(), None, false, false, None, true); + assert!( + matches!(ambiguous, InstallMethod::Unknown { .. }), + "{ambiguous:?}" + ); + } + + #[test] + fn detects_bun_path_by_marker_without_global_probe() { let path = PathBuf::from("/Users/me/.bun/install/global/node_modules/crabcode/bin/crabcode"); - match detect_install_method_from_path(&path) { + // Bun path marker is authoritative even when the ownership probe is + // unavailable (e.g. `bun` not on PATH in this env). + let method = detect_with(&path, &fake_cargo_bin(), None, false, false, None, false); + match method { InstallMethod::Js { manager: JsPackageManager::Bun, } => {} @@ -593,29 +1187,131 @@ mod tests { } #[test] - fn detects_cargo_bin_path() { - let home = home_dir().expect("home"); - let path = home.join(".cargo/bin/crabcode"); - let method = detect_install_method_from_path(&path); + fn cargo_bin_with_cargo_claim_only_is_cargo() { + let cargo_bin = fake_cargo_bin(); + let path = cargo_bin.join("crabcode"); + let method = detect_with(&path, &cargo_bin, None, true, false, None, false); assert!(matches!(method, InstallMethod::Cargo { .. }), "{method:?}"); } + #[test] + fn cargo_bin_with_receipt_only_is_shell_installer() { + // cargo-dist shell installer defaults to $CARGO_HOME/bin: receipt + // present + cargo silent must NOT be misclassified as Cargo. + let cargo_bin = fake_cargo_bin(); + let path = cargo_bin.join("crabcode"); + let receipt = shell_receipt_for("/Users/me/.cargo"); + assert!(receipt.claims_path(&path)); + let method = detect_with(&path, &cargo_bin, Some(receipt), false, false, None, false); + assert_eq!(method, InstallMethod::InstallScript); + } + + #[test] + fn cargo_bin_with_neither_or_both_claims_is_unknown() { + let cargo_bin = fake_cargo_bin(); + let path = cargo_bin.join("crabcode"); + // Neither signal (unmanaged copy) => fail safe. + let neither = detect_with(&path, &cargo_bin, None, false, false, None, false); + assert!( + matches!(neither, InstallMethod::Unknown { .. }), + "{neither:?}" + ); + // Both signals (double install) => ambiguous, fail safe. + let receipt = shell_receipt_for("/Users/me/.cargo"); + let both = detect_with(&path, &cargo_bin, Some(receipt), true, false, None, false); + assert!(matches!(both, InstallMethod::Unknown { .. }), "{both:?}"); + } + + #[test] + fn custom_cargo_home_bin_is_disambiguated_same_as_default() { + // Custom CARGO_HOME must use the same receipt-vs-cargo rule, not a + // hardcoded ~/.cargo/bin prefix. + let cargo_bin = PathBuf::from("/custom/cargo/bin"); + let path = cargo_bin.join("crabcode"); + let receipt = shell_receipt_for("/custom/cargo"); + assert!(receipt.claims_path(&path)); + let shell = detect_with(&path, &cargo_bin, Some(receipt), false, false, None, false); + assert_eq!(shell, InstallMethod::InstallScript); + let cargo = detect_with(&path, &cargo_bin, None, true, false, None, false); + assert!(matches!(cargo, InstallMethod::Cargo { .. }), "{cargo:?}"); + } + + #[test] + fn receipt_must_own_binary_and_prefix_to_claim() { + let path = PathBuf::from("/Users/me/.cargo/bin/crabcode"); + let mut receipt = shell_receipt_for("/Users/me/.cargo"); + assert!(receipt.claims_path(&path)); + // Wrong binary name => no claim. + receipt.binaries = vec!["other-tool".to_string()]; + assert!(!receipt.claims_path(&path)); + // Wrong prefix => no claim. + receipt.binaries = vec![BINARY_NAME.to_string()]; + receipt.install_prefix = "/other/prefix".to_string(); + assert!(!receipt.claims_path(&path)); + // Alias ownership still counts. + receipt.install_prefix = "/Users/me/.cargo".to_string(); + receipt.binaries.clear(); + receipt + .binary_aliases + .insert(BINARY_NAME.to_string(), vec!["alias".to_string()]); + assert!(receipt.claims_path(&path)); + } + + #[test] + fn receipt_prefix_placeholders_expand() { + // $CARGO_HOME placeholder resolves via cargo_home() (no panic on + // unset env); absolute paths pass through unchanged. + let abs = expand_receipt_prefix("/Users/me/.cargo"); + assert_eq!(abs, PathBuf::from("/Users/me/.cargo")); + assert!(expand_receipt_prefix("").as_os_str().is_empty()); + // Placeholder branches must not panic regardless of env. + let _ = expand_receipt_prefix("$CARGO_HOME/bin"); + let _ = expand_receipt_prefix("$HOME/.cargo"); + let _ = expand_receipt_prefix("~/.cargo"); + } + + #[test] + fn ambiguous_path_prefers_strict_js_owner_over_guess() { + // Non-node_modules path with a global-list hit but no single owner + // must fail safe instead of upgrading via an unrelated manager. + let path = PathBuf::from("/usr/local/bin/crabcode"); + let ambiguous = detect_with(&path, &fake_cargo_bin(), None, false, false, None, true); + assert!( + matches!(ambiguous, InstallMethod::Unknown { .. }), + "{ambiguous:?}" + ); + let owned = detect_with( + &path, + &fake_cargo_bin(), + None, + false, + false, + Some(JsPackageManager::Npm), + true, + ); + assert!( + matches!( + owned, + InstallMethod::Js { + manager: JsPackageManager::Npm + } + ), + "{owned:?}" + ); + } + #[test] fn detects_install_script_path() { let path = PathBuf::from("/Users/me/.local/bin/crabcode"); - assert_eq!( - detect_install_method_from_path(&path), - InstallMethod::InstallScript - ); + let method = detect_with(&path, &fake_cargo_bin(), None, false, false, None, false); + assert_eq!(method, InstallMethod::InstallScript); } #[test] fn detects_homebrew_cellar_path() { let path = PathBuf::from("/opt/homebrew/Cellar/crabcode/0.0.10/bin/crabcode"); - assert_eq!( - detect_install_method_from_path(&path), - InstallMethod::Homebrew - ); + let method = detect_with(&path, &fake_cargo_bin(), None, false, false, None, false); + assert_eq!(method, InstallMethod::Homebrew); } #[test] @@ -624,4 +1320,40 @@ mod tests { assert!(!check.needs_upgrade); assert_eq!(check.target, "0.0.10"); } + + #[test] + fn current_version_matches_package() { + assert_eq!(current_version(), env!("CARGO_PKG_VERSION")); + assert!(!current_version().is_empty()); + } + + #[test] + fn noninteractive_upgrade_is_noop_when_already_current() { + // Explicit same-version target never touches the network or installers. + let current = current_version(); + let target = upgrade_noninteractive(Some(¤t)).unwrap(); + assert_eq!(target, current.trim().trim_start_matches('v')); + } + + #[test] + fn tail_text_keeps_short_output_whole() { + assert_eq!(tail_text(b" boom\n", 800), "boom"); + assert_eq!(tail_text(b"", 800), ""); + } + + #[test] + fn tail_text_truncates_to_trailing_chars() { + let long = "x".repeat(1000); + let tail = tail_text(long.as_bytes(), 800); + assert_eq!(tail.len(), 800); + assert_eq!(tail, "x".repeat(800)); + } + + #[test] + fn unknown_method_help_names_alternatives() { + let help = unknown_method_help(Path::new("/tmp/odd-place/crabcode")); + assert!(help.contains("could not determine install method")); + assert!(help.contains("brew install")); + assert!(help.contains("npm install -g")); + } }