diff --git a/.github/release-notes-1.5.23.md b/.github/release-notes-1.5.23.md new file mode 100644 index 00000000..3e65ea0d --- /dev/null +++ b/.github/release-notes-1.5.23.md @@ -0,0 +1,35 @@ +## Highlights + +### Ceiling tells you when you're going to run out + +Ceiling already predicted whether a quota would outlast its window. It just never said so anywhere you'd see it: the expected-vs-actual breakdown is too tall for the tray, so it was hidden there, which is exactly where people look. + +The tray now carries a verdict. It names the outcome (on track, ahead of pace, plenty left, or running out early), says what that means for you, and draws one slim bar with a tick showing where your usage *should* be by this point in the window. If you're burning through it, it tells you roughly how long you have left. The detailed breakdown is still there in the main window. + +### Predictive pace warnings are back, if you want them + +There is a warning that fires when a window is on course to be exhausted before it resets. It had been switched off on every launch with no way to turn it on, and even then it only ever applied to Claude and Codex. + +It is available again under **Settings > Notifications**, off by default. Any provider that reports a reset can raise one, and the warning now names the window by its real cadence instead of calling a monthly quota a "session". + +### Cursor on-demand spend is visible in dollars + +If you hit 100% of your Cursor plan, on-demand is where the real money goes, and it was easy to miss. It could vanish entirely on some account shapes, and anyone running on-demand without a spend cap saw nothing at all, because a meter needs a cap to draw a percentage against. + +On-demand now shows its dollars beside its bar, uncapped spend is reported as a plain figure rather than dropped, and it takes the cost slot ahead of plan usage. Only on-demand bills real money on Cursor, so it should be the number you see. + +### A Cursor meter that could only ever say 0% is gone + +The Cursor card carried a "Promotional" bar that was pinned at 0% by its own arithmetic, and a badge claiming the bonus expired at the end of the billing cycle when Cursor credits expire on their own schedule. Both numbers were wrong, and neither can be worked out from what the Cursor API actually reports, so the lane has been removed rather than guessed at. + +## Installers + +- **Ceiling-1.5.23-Setup.exe** - standard installer +- **Ceiling-1.5.23-portable.exe** - portable +- **Ceiling-1.5.23-Store-Setup.exe** - Microsoft Store package (WebView2 bundled) + +Portable builds show alerts as banners but do not keep them in the notification center. That requires the Start Menu shortcut installed by the standard or Store build. + +--- + +**Full Changelog**: https://github.com/tsouth89/ceiling/compare/v1.5.22...v1.5.23 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3f27e1..678056a7 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [Ceiling] 1.5.23 - 2026-08-05 + +Adds a pace verdict and Cursor on-demand spend, both from user feature requests. + +### Added +- The tray now answers "am I going to run out before this resets?" directly. A verdict line names the outcome (on track, ahead of pace, plenty left, or running out early), states the consequence, and draws one slim bar with a tick showing where usage should be by this point in the window. The detailed expected-vs-actual breakdown was hidden in the tray because it was too tall, so the prediction Ceiling already computed was invisible exactly where people look for it. The taller breakdown still appears in the main window. +- Predictive pace warnings are available again, as an opt-in under Settings > Notifications. They alert when a window is on course to be exhausted before it resets. The setting had been pinned off on every load with no way to enable it, and was additionally restricted to Claude and Codex; any provider that reports a reset can raise one now. Warnings are named by the window's real cadence, so a monthly quota is no longer announced as a "Session" limit. +- A metered window can carry the money behind it. Cursor's on-demand lane now shows its dollars beside its bar, which is what makes the overdraft readable at 100% of plan. + +### Fixed +- Cursor on-demand usage could disappear entirely. It was only read when the account reported a `plan` object, so accounts reporting `overall` lost the overdraft meter; and deriving a percentage needs a cap, so anyone running on-demand uncapped got no meter and their spend was dropped. Uncapped spend is now reported as an explicit non-metering line rather than being discarded for want of a denominator. +- Cursor on-demand is the only lane on that provider that bills real money, so it now takes the cost slot ahead of plan and pooled-team usage, and is labelled "On-demand" instead of being folded into a generic "Monthly". +- Removed the Cursor "Promotional" meter and its badge. The percentage was computed as plan usage minus the included allotment, and the included lane is its own closed set, so the subtraction was always zero and the meter read 0% permanently. The badge alongside it claimed the bonus expired at the end of the billing cycle, while Cursor credits expire on their own schedule. Both numbers were wrong and neither can be derived from what the API reports, so the lane is gone rather than guessed at. + +### Internal +- Cost-scanner tests no longer mutate `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME`. Changing process environment while another thread reads it is undefined behaviour, and nine other modules read those variables constantly, so unrelated scans intermittently resolved the wrong home. One failure also poisoned the shared mutex and cascaded into three more, which disguised a single fault as four. The scanner now accepts an injected ambient home; the suite went from failing roughly one run in five to twelve clean runs. + ## [Ceiling] 1.5.22 - 2026-08-03 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index b59ea796..5f87aee3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -664,7 +664,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codexbar" -version = "1.5.22" +version = "1.5.23" dependencies = [ "aes-gcm", "anyhow", @@ -706,7 +706,7 @@ dependencies = [ [[package]] name = "codexbar-desktop-tauri" -version = "1.5.22" +version = "1.5.23" dependencies = [ "chrono", "chrono-tz", diff --git a/apps/desktop-tauri/package.json b/apps/desktop-tauri/package.json index ee03f886..498e6bcb 100644 --- a/apps/desktop-tauri/package.json +++ b/apps/desktop-tauri/package.json @@ -1,7 +1,7 @@ { "name": "desktop-tauri", "private": true, - "version": "1.5.22", + "version": "1.5.23", "packageManager": "pnpm@10.18.1", "type": "module", "scripts": { diff --git a/apps/desktop-tauri/src-tauri/Cargo.toml b/apps/desktop-tauri/src-tauri/Cargo.toml index c356d847..025e20ba 100644 --- a/apps/desktop-tauri/src-tauri/Cargo.toml +++ b/apps/desktop-tauri/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codexbar-desktop-tauri" -version = "1.5.22" +version = "1.5.23" edition = "2024" publish = false diff --git a/apps/desktop-tauri/src-tauri/tauri.conf.json b/apps/desktop-tauri/src-tauri/tauri.conf.json index ae4552f1..f0c81b04 100644 --- a/apps/desktop-tauri/src-tauri/tauri.conf.json +++ b/apps/desktop-tauri/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Ceiling", - "version": "1.5.22", + "version": "1.5.23", "identifier": "io.github.tsouth89.ceiling", "build": { "beforeDevCommand": "pnpm run dev", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 0f0ba92a..1a089ac9 100755 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codexbar" -version = "1.5.22" +version = "1.5.23" edition = "2024" authors = ["Ceiling Contributors"] description = "Local-first Windows companion for monitoring AI capacity and reset times" diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 9411b400..0367301a 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -593,6 +593,15 @@ pub struct CostScanner { /// [`ConfiguredAccounts`] (unit tests inject temp dirs). Production leaves /// this `None` so live multi-account setup is always read at scan time. account_homes_override: Option>, + /// When set, stands in for the ambient config home of whichever provider is + /// being scanned: `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `GROK_HOME`. + /// + /// Tests used to `set_var` these instead. Mutating process environment is + /// undefined behaviour while any other thread reads it, and the rest of the + /// suite reads both constantly, which made unrelated scans intermittently + /// resolve the wrong home. Injecting the value keeps it thread-local to the + /// scanner. Production leaves this `None` and reads the real environment. + ambient_home_override: Option, } impl CostScanner { @@ -609,6 +618,7 @@ impl CostScanner { scoped_home: None, codex_speed: CodexCostSpeed::resolve(None), account_homes_override: None, + ambient_home_override: None, } } @@ -619,6 +629,7 @@ impl CostScanner { scoped_home: None, codex_speed: CodexCostSpeed::resolve(speed_override), account_homes_override: None, + ambient_home_override: None, } } @@ -629,18 +640,28 @@ impl CostScanner { scoped_home: Some(home), codex_speed: CodexCostSpeed::resolve(None), account_homes_override: None, + ambient_home_override: None, } } - /// Unscoped scanner that treats `homes` as the only configured account - /// directories (no live [`ConfiguredAccounts`] load). Used by tests. + /// Stand in for the ambient config home on an already-built scanner. + #[cfg(test)] + fn with_ambient_home(mut self, home: PathBuf) -> Self { + self.ambient_home_override = Some(home); + self + } + + /// Treats `homes` as the only configured account directories (no live + /// [`ConfiguredAccounts`] load) and stands in for the ambient config + /// directory, instead of mutating the process environment. #[cfg(test)] - fn with_account_homes(days: u32, homes: Vec) -> Self { + fn with_ambient_and_account_homes(days: u32, ambient: PathBuf, homes: Vec) -> Self { Self { days, scoped_home: None, codex_speed: CodexCostSpeed::resolve(None), account_homes_override: Some(homes), + ambient_home_override: Some(ambient), } } @@ -784,7 +805,10 @@ impl CostScanner { ); } let settings = Settings::load(); - let codex_home = std::env::var("CODEX_HOME").ok(); + let codex_home = match &self.ambient_home_override { + Some(home) => Some(home.to_string_lossy().into_owned()), + None => std::env::var("CODEX_HOME").ok(), + }; // Merge custom session roots with every configured multi-account // CODEX_HOME. `codex_sessions_dir_candidates` dedups by path so an // account that is also ambient / listed in custom dirs is not double @@ -903,7 +927,11 @@ impl CostScanner { } }; - if let Ok(claude_config) = std::env::var("CLAUDE_CONFIG_DIR") { + let ambient = match &self.ambient_home_override { + Some(home) => Ok(home.to_string_lossy().into_owned()), + None => std::env::var("CLAUDE_CONFIG_DIR"), + }; + if let Ok(claude_config) = ambient { let trimmed = claude_config.trim(); if !trimmed.is_empty() { push( @@ -1759,10 +1787,11 @@ fn scan_grok_report( days: u32, windows: &[CurrentUsageWindow], ) -> CostUsageReport { - let _ = scanner; let mut daily = empty_daily_summaries(days); let mut current_windows = empty_current_window_summaries(windows); - let Some(sessions_root) = grok_sessions_dir(None) else { + // Honour an injected home so tests need not export `GROK_HOME`. + let ambient = scanner.ambient_home_override.clone(); + let Some(sessions_root) = grok_sessions_dir(ambient.as_deref()) else { return finish_report(daily, days, None, (0, 0, 0), None, current_windows); }; if !sessions_root.exists() { @@ -2098,11 +2127,6 @@ mod tests { )); } - fn codex_home_lock() -> &'static std::sync::Mutex<()> { - static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); - LOCK.get_or_init(|| std::sync::Mutex::new(())) - } - /// SOU-296: the report scanner behind the Charts page, the reset windows, /// and the API value card only walked `sessions/`. Archiving a Codex task /// therefore shrank every total, while the summary scanner still counted @@ -2177,7 +2201,6 @@ mod tests { /// dirs were walked). #[test] fn unscoped_scan_sums_configured_account_homes() { - let _guard = codex_home_lock().lock().expect("codex home lock"); let today = Local::now().date_naive(); let day = today.format("%Y-%m-%d").to_string(); let ts = format!("{day}T10:00:00.000Z"); @@ -2215,19 +2238,26 @@ mod tests { ) .unwrap(); - let previous = std::env::var("CODEX_HOME").ok(); // Ambient home is personal; work is only present as a configured account. - // SAFETY: serialized by `codex_home_lock`, and restored below. - unsafe { std::env::set_var("CODEX_HOME", personal.path()) }; + // Injected rather than exported: mutating process env races the readers + // in every other test module. + let ambient = personal.path().to_path_buf(); // Without account homes, only ambient (personal) is scanned. - let ambient_only = - scan_codex_report(&CostScanner::with_account_homes(2, Vec::new()), 2, &[]); + let ambient_only = scan_codex_report( + &CostScanner::with_ambient_and_account_homes(2, ambient.clone(), Vec::new()), + 2, + &[], + ); assert_eq!(ambient_only.thirty_days.input_tokens, 1000); // With the work account configured, totals include both homes. let both = scan_codex_report( - &CostScanner::with_account_homes(2, vec![work.path().to_path_buf()]), + &CostScanner::with_ambient_and_account_homes( + 2, + ambient.clone(), + vec![work.path().to_path_buf()], + ), 2, &[], ); @@ -2239,8 +2269,9 @@ mod tests { // Listing the ambient home again as an "account" must not double-count. let with_dup_home = scan_codex_report( - &CostScanner::with_account_homes( + &CostScanner::with_ambient_and_account_homes( 2, + ambient, vec![personal.path().to_path_buf(), work.path().to_path_buf()], ), 2, @@ -2250,19 +2281,11 @@ mod tests { with_dup_home.thirty_days.input_tokens, 8000, "same config dir listed as ambient and account must not inflate" ); - - unsafe { - match previous { - Some(value) => std::env::set_var("CODEX_HOME", value), - None => std::env::remove_var("CODEX_HOME"), - } - } } /// A rollout copied into two homes still counts once (filename dedup). #[test] fn unscoped_scan_dedups_same_rollout_across_account_homes() { - let _guard = codex_home_lock().lock().expect("codex home lock"); let today = Local::now().date_naive(); let day = today.format("%Y-%m-%d").to_string(); let ts = format!("{day}T10:00:00.000Z"); @@ -2287,11 +2310,12 @@ mod tests { std::fs::write(pd.join(&shared), &token_line).unwrap(); std::fs::write(wd.join(&shared), &token_line).unwrap(); - let previous = std::env::var("CODEX_HOME").ok(); - unsafe { std::env::set_var("CODEX_HOME", personal.path()) }; - let report = scan_codex_report( - &CostScanner::with_account_homes(2, vec![work.path().to_path_buf()]), + &CostScanner::with_ambient_and_account_homes( + 2, + personal.path().to_path_buf(), + vec![work.path().to_path_buf()], + ), 2, &[], ); @@ -2300,18 +2324,10 @@ mod tests { "identical rollout name across homes must count once" ); assert_eq!(report.thirty_days.sessions_count, 1); - - unsafe { - match previous { - Some(value) => std::env::set_var("CODEX_HOME", value), - None => std::env::remove_var("CODEX_HOME"), - } - } } #[test] fn codex_report_counts_archived_rollouts_exactly_once() { - let _guard = codex_home_lock().lock().expect("codex home lock"); let today = Local::now().date_naive(); let day = today.format("%Y-%m-%d").to_string(); let ts = format!("{day}T10:00:00.000Z"); @@ -2336,16 +2352,11 @@ mod tests { std::fs::write(archived.join(&shared), &token_line).unwrap(); std::fs::write(archived.join(&archived_only), &token_line).unwrap(); - let previous = std::env::var("CODEX_HOME").ok(); - // SAFETY: serialized by `codex_home_lock`, and restored below. - unsafe { std::env::set_var("CODEX_HOME", tmp.path()) }; - let report = scan_codex_report(&CostScanner::new(2), 2, &[]); - unsafe { - match previous { - Some(value) => std::env::set_var("CODEX_HOME", value), - None => std::env::remove_var("CODEX_HOME"), - } - } + let report = scan_codex_report( + &CostScanner::with_ambient_and_account_homes(2, tmp.path().to_path_buf(), Vec::new()), + 2, + &[], + ); assert_eq!( report.thirty_days.sessions_count, 2, @@ -2568,11 +2579,6 @@ mod tests { ) .unwrap(); - let _guard = codex_home_lock().lock().expect("codex home lock"); - let previous = std::env::var("CODEX_HOME").ok(); - // SAFETY: serialized by `codex_home_lock`, and restored below. - unsafe { std::env::set_var("CODEX_HOME", tmp.path()) }; - let starts_at = Utc::now() - Duration::hours(1); let ends_at = Utc::now() + Duration::hours(1); let windows = [CurrentUsageWindow { @@ -2581,18 +2587,13 @@ mod tests { ends_at, }]; - let standard = CostScanner::with_codex_speed(2, Some("standard")); - let fast = CostScanner::with_codex_speed(2, Some("fast")); + let ambient = tmp.path().to_path_buf(); + let standard = + CostScanner::with_codex_speed(2, Some("standard")).with_ambient_home(ambient.clone()); + let fast = CostScanner::with_codex_speed(2, Some("fast")).with_ambient_home(ambient); let std_report = scan_codex_report(&standard, 2, &windows); let fast_report = scan_codex_report(&fast, 2, &windows); - unsafe { - match previous { - Some(value) => std::env::set_var("CODEX_HOME", value), - None => std::env::remove_var("CODEX_HOME"), - } - } - let std_window = std_report .current_windows .get("primary") @@ -2701,7 +2702,6 @@ mod tests { #[test] fn unscoped_claude_scan_sums_account_homes_and_dedups_records() { - let _guard = codex_home_lock().lock().expect("environment lock"); let personal = tempfile::tempdir().expect("personal home"); let work = tempfile::tempdir().expect("work home"); let personal_projects = personal.path().join("projects").join("personal"); @@ -2730,25 +2730,20 @@ mod tests { ) .expect("work transcript"); - let previous = std::env::var("CLAUDE_CONFIG_DIR").ok(); - // SAFETY: serialized by the environment lock and restored below. - unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", personal.path()) }; - - let summary = - CostScanner::with_account_homes(2, vec![work.path().to_path_buf()]).scan_claude(); + // The ambient home is injected rather than exported: mutating the + // process environment races every other test that reads it. + let summary = CostScanner::with_ambient_and_account_homes( + 2, + personal.path().to_path_buf(), + vec![work.path().to_path_buf()], + ) + .scan_claude(); assert_eq!( summary.input_tokens, 1000, "shared 100 + personal 200 + work 700" ); assert_eq!(summary.output_tokens, 100); assert_eq!(summary.sessions_count, 2); - - unsafe { - match previous { - Some(value) => std::env::set_var("CLAUDE_CONFIG_DIR", value), - None => std::env::remove_var("CLAUDE_CONFIG_DIR"), - } - } } #[test] @@ -2920,16 +2915,11 @@ mod tests { .unwrap(); // SAFETY: test-only env override; restored after the scan. - let prev = std::env::var_os("GROK_HOME"); - // SAFETY: single-threaded test isolation for GROK_HOME. - unsafe { - std::env::set_var("GROK_HOME", home.path()); - } - let report = scan_grok_report(&CostScanner::new(7), 7, &[]); - match prev { - Some(value) => unsafe { std::env::set_var("GROK_HOME", value) }, - None => unsafe { std::env::remove_var("GROK_HOME") }, - } + let report = scan_grok_report( + &CostScanner::new(7).with_ambient_home(home.path().to_path_buf()), + 7, + &[], + ); assert_eq!(report.thirty_days.sessions_count, 1); assert_eq!(report.thirty_days.input_tokens, 1000); @@ -2984,16 +2974,11 @@ mod tests { ) .unwrap(); - // SAFETY: same test-only GROK_HOME isolation as above. - let prev = std::env::var_os("GROK_HOME"); - unsafe { - std::env::set_var("GROK_HOME", home.path()); - } - let mixed = scan_grok_report(&CostScanner::new(7), 7, &[]); - match prev { - Some(value) => unsafe { std::env::set_var("GROK_HOME", value) }, - None => unsafe { std::env::remove_var("GROK_HOME") }, - } + let mixed = scan_grok_report( + &CostScanner::new(7).with_ambient_home(home.path().to_path_buf()), + 7, + &[], + ); assert_eq!(mixed.thirty_days.input_tokens, 51_000); // Only the priced turn contributes dollars. diff --git a/rust/src/providers/cursor/api.rs b/rust/src/providers/cursor/api.rs index da0c2475..37be2e86 100755 --- a/rust/src/providers/cursor/api.rs +++ b/rust/src/providers/cursor/api.rs @@ -3,8 +3,8 @@ //! Uses browser cookies to authenticate with cursor.com API use crate::core::{ - CostSnapshot, InactiveRateWindow, NamedRateWindow, PromoSignal, ProviderError, RateWindow, - UsageSnapshot, WindowAmount, + CostSnapshot, InactiveRateWindow, NamedRateWindow, ProviderError, RateWindow, UsageSnapshot, + WindowAmount, }; use crate::providers::browser_cookie_header; use chrono::{DateTime, Utc}; @@ -162,10 +162,6 @@ impl CursorApi { )); } - if let Some(promo) = promotional_window(plan, window_minutes, billing_end) { - extras.push(promo); - } - // When Cursor reports the modern percent-lane payload, omitted Auto/API // lanes are explicit absences — not fabricated 0% meters. if uses_percent_lanes(plan) { @@ -256,21 +252,6 @@ impl CursorApi { usage.extra_rate_windows = extras; usage.inactive_rate_windows = inactives; - if let Some(promo) = usage - .extra_rate_windows - .iter() - .find(|w| w.id == "cursor-promotional") - { - let ends_at = promo.window.resets_at; - usage = usage.with_promo_signal(PromoSignal::boost( - "cursor-promotional", - "Promotional", - "Bonus promotional capacity reported by Cursor", - Some("cursor-promotional".to_string()), - ends_at, - )); - } - if let Some(email) = user_info.as_ref().and_then(|u| u.email.clone()) { usage = usage.with_email(email); } @@ -436,25 +417,6 @@ fn uses_percent_lanes(plan: &PlanUsage) -> bool { || plan.api_percent_used.is_some() } -fn promotional_window( - plan: &PlanUsage, - window_minutes: Option, - billing_end: Option>, -) -> Option { - let breakdown = plan.breakdown.as_ref()?; - let bonus = breakdown.bonus.filter(|&b| b > 0)? as f64; - let included = breakdown.included.filter(|&v| v >= 0)? as f64; - let used = plan.used.filter(|&v| v >= 0)? as f64; - let promo_used = (used - included).clamp(0.0, bonus); - let percent = promo_used / bonus * 100.0; - - Some(NamedRateWindow::new( - "cursor-promotional", - "Promotional", - RateWindow::with_details(percent, window_minutes, billing_end, None), - )) -} - /// Cursor reports on-demand two ways: as a capped pool that can be metered, or /// as uncapped spend with no limit at all. Uncapped overdraft is still real /// money, so surface it as an explicit non-metering window instead of dropping @@ -676,10 +638,14 @@ mod tests { assert!((usage.secondary.as_ref().unwrap().used_percent - 17.2).abs() < 0.01); assert!((extra(&usage, "cursor-api").window.used_percent - 0.0).abs() < 0.01); - let promo = extra(&usage, "cursor-promotional"); - assert_eq!(promo.title, "Promotional"); - // used == included, so promotional pool is untouched - assert!((promo.window.used_percent - 0.0).abs() < 0.01); + // No Promotional lane: `breakdown.bonus` is bonus *consumed*, and the + // grant it would be metered against is nowhere in the payload. + assert!( + !usage + .extra_rate_windows + .iter() + .any(|w| w.id == "cursor-promotional") + ); let cost = cost.expect("plan usage should still produce cost snapshot"); assert!((cost.used - 20.0).abs() < 0.01); @@ -688,67 +654,47 @@ mod tests { } #[test] - fn test_cursor_promotional_partial_consumption() { + fn test_cursor_build_result_cents_only() { let json = r#"{ "billingCycleEnd": "2026-04-01T00:00:00Z", "membershipType": "pro", "individualUsage": { "plan": { - "used": 2300, - "limit": 2580, - "breakdown": { - "included": 2000, - "bonus": 580, - "total": 2580 - }, - "totalPercentUsed": 89.14728682170542 + "used": 2500, + "limit": 5000 } } }"#; let summary = parse_summary(json); - let (usage, _) = api().build_result(summary, None).unwrap(); - - let promo = extra(&usage, "cursor-promotional"); - assert!((promo.window.used_percent - (300.0 / 580.0 * 100.0)).abs() < 0.01); - assert!(promo.window.resets_at.is_some()); + let (usage, cost) = api().build_result(summary, None).unwrap(); - // Lane format with only totalPercentUsed → Auto/API not enforced - assert_eq!(inactive(&usage, "cursor-auto").description, NOT_ENFORCED); - assert_eq!(inactive(&usage, "cursor-api").description, NOT_ENFORCED); - assert!(usage.secondary.is_none()); + assert!((usage.primary.used_percent - 50.0).abs() < 0.01); + assert!(usage.secondary.is_none(), "no autoPercentUsed in payload"); + assert!(usage.extra_rate_windows.is_empty()); assert!( - !usage - .extra_rate_windows - .iter() - .any(|w| w.id == "cursor-api") + usage.inactive_rate_windows.is_empty(), + "cents-only payloads are not the percent-lane format" ); + assert!(cost.is_some()); } #[test] - fn test_cursor_build_result_cents_only() { + fn test_cursor_percent_falls_back_to_the_breakdown_total_limit() { + // No `totalPercentUsed` and no `plan.limit`: the percentage falls back + // to metering `used` against `breakdown.total`. let json = r#"{ - "billingCycleEnd": "2026-04-01T00:00:00Z", - "membershipType": "pro", "individualUsage": { "plan": { - "used": 2500, - "limit": 5000 + "used": 1250, + "breakdown": {"included": 5000, "bonus": 0, "total": 5000} } } }"#; let summary = parse_summary(json); - let (usage, cost) = api().build_result(summary, None).unwrap(); - - assert!((usage.primary.used_percent - 50.0).abs() < 0.01); - assert!(usage.secondary.is_none(), "no autoPercentUsed in payload"); - assert!(usage.extra_rate_windows.is_empty()); - assert!( - usage.inactive_rate_windows.is_empty(), - "cents-only payloads are not the percent-lane format" - ); - assert!(cost.is_some()); + let (usage, _) = api().build_result(summary, None).unwrap(); + assert!((usage.primary.used_percent - 25.0).abs() < 0.01); } #[test] @@ -768,22 +714,6 @@ mod tests { assert!(cost.is_none()); } - #[test] - fn test_cursor_unlimited_without_monthly_meter() { - let json = r#"{ - "membershipType": "hobby", - "isUnlimited": true, - "individualUsage": {} - }"#; - - let summary = parse_summary(json); - let (usage, _) = api().build_result(summary, None).unwrap(); - - let monthly = inactive(&usage, "cursor-monthly"); - assert_eq!(monthly.title, "Monthly"); - assert_eq!(monthly.description, NOT_ENFORCED); - } - #[test] fn test_cursor_on_demand_as_named_extra_and_cost() { let json = r#"{ @@ -820,7 +750,7 @@ mod tests { assert_eq!(amount.limit, Some(10.0)); assert_eq!(amount.format_used(), "$3.50"); - // Lane format with only total → Auto/API inactive + // Lane format with only total → Auto/API inactive assert_eq!(usage.inactive_rate_windows.len(), 2); // On-demand is the only real-money lane, so it owns the cost slot. @@ -830,6 +760,62 @@ mod tests { assert_eq!(cost.period, "On-demand"); } + #[test] + fn test_cursor_unlimited_without_monthly_meter() { + let json = r#"{ + "membershipType": "hobby", + "isUnlimited": true, + "individualUsage": {} + }"#; + + let summary = parse_summary(json); + let (usage, _) = api().build_result(summary, None).unwrap(); + + let monthly = inactive(&usage, "cursor-monthly"); + assert_eq!(monthly.title, "Monthly"); + assert_eq!(monthly.description, NOT_ENFORCED); + } + + #[test] + fn test_cursor_reports_no_promotional_meter() { + // The old meter computed `plan.used - breakdown.included`, which is + // structurally zero: the included lane is its own closed set, so it + // read 0% forever. `breakdown.bonus` is bonus consumed, not a grant, + // so there is nothing honest to meter it against. + let json = r#"{ + "billingCycleEnd": "2026-04-01T00:00:00Z", + "membershipType": "pro", + "individualUsage": { + "plan": { + "used": 2000, + "limit": 2000, + "remaining": 0, + "breakdown": { + "included": 2000, + "bonus": 23466, + "total": 25466 + }, + "totalPercentUsed": 73.81449275362318 + } + } + }"#; + + let summary = parse_summary(json); + let (usage, _) = api().build_result(summary, None).unwrap(); + + assert!( + !usage + .extra_rate_windows + .iter() + .any(|w| w.id == "cursor-promotional") + ); + // And no promo badge, which carried a billing-cycle end date while + // Cursor credits expire on their own schedule. + assert!(usage.promo_signals.is_empty()); + // The primary meter is unaffected: it reads totalPercentUsed directly. + assert!((usage.primary.used_percent - 73.81449275362318).abs() < 0.01); + } + #[test] fn test_cursor_uncapped_on_demand_reports_spend() { // On-demand enabled with real spend but no cap: there is no denominator @@ -1011,33 +997,4 @@ mod tests { assert_eq!(cost.unwrap().used, 50.0); assert!(usage.extra_rate_windows.is_empty()); } - - #[test] - fn test_cursor_does_not_invent_promotional_without_bonus() { - let json = r#"{ - "individualUsage": { - "plan": { - "used": 1000, - "limit": 2000, - "breakdown": { - "included": 2000, - "bonus": 0, - "total": 2000 - }, - "totalPercentUsed": 50.0, - "autoPercentUsed": 40.0, - "apiPercentUsed": 10.0 - } - } - }"#; - - let summary = parse_summary(json); - let (usage, _) = api().build_result(summary, None).unwrap(); - assert!( - !usage - .extra_rate_windows - .iter() - .any(|w| w.id == "cursor-promotional") - ); - } } diff --git a/version.env b/version.env index fc205703..7a90c9a1 100755 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=1.5.22 -BUILD_NUMBER=124 +MARKETING_VERSION=1.5.23 +BUILD_NUMBER=125