From 34fd651e4cc9e2cfe23df3e99ecec0f3b1dbe925 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:08:36 +0700 Subject: [PATCH 1/2] Match MiMo card text to the Mac: Credits label, grouped credits detail, Balance row --- rust/src/providers/mimo/mod.rs | 237 +++++++++++++++++++++++++-------- 1 file changed, 184 insertions(+), 53 deletions(-) diff --git a/rust/src/providers/mimo/mod.rs b/rust/src/providers/mimo/mod.rs index 43e95a59df..b5d5e743c5 100644 --- a/rust/src/providers/mimo/mod.rs +++ b/rust/src/providers/mimo/mod.rs @@ -8,11 +8,12 @@ use reqwest::Client; use serde::Deserialize; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, }; const MIMO_API_BASE: &str = "https://platform.xiaomimimo.com/api/v1"; +const BALANCE_DETAIL_ID: &str = "mimo-balance"; pub struct MiMoProvider { metadata: ProviderMetadata, @@ -83,8 +84,8 @@ impl MiMoProvider { metadata: ProviderMetadata { id: ProviderId::MiMo, display_name: "Xiaomi MiMo", - session_label: "Tokens", - weekly_label: "Balance", + session_label: "Credits", + weekly_label: "Window", supports_opus: false, supports_credits: true, default_enabled: false, @@ -100,7 +101,7 @@ impl MiMoProvider { } } - async fn fetch_web(&self, cookie_header: &str) -> Result { + async fn fetch_web(&self, cookie_header: &str) -> Result { let cookie = normalize_cookie_header(cookie_header).ok_or(ProviderError::NoCookies)?; let balance: BalanceResponse = self.get_json("balance", &cookie).await?; if balance.code == 401 { @@ -124,14 +125,14 @@ impl MiMoProvider { self.get_json("tokenPlan/detail", &cookie).await.ok(); let usage: Option = self.get_json("tokenPlan/usage", &cookie).await.ok(); - Ok(snapshot_from_parts( + let balance_row = balance_detail( balance_value, - data.currency, - data.cash_balance, - data.gift_balance, - detail, - usage, - )) + &data.currency, + data.cash_balance.as_deref(), + data.gift_balance.as_deref(), + ); + let snapshot = snapshot_from_parts(detail, usage); + Ok(ProviderFetchResult::new(snapshot, "web").with_display_detail(balance_row)) } async fn get_json Deserialize<'de>>( @@ -207,11 +208,10 @@ fn normalize_cookie_header(raw: &str) -> Option { } } +/// Mirrors upstream `MiMoUsageSnapshot.toUsageSnapshot()`: the token plan is the +/// only bar, the plan code is the title-cased plan label, and the balance is a +/// "Credits" detail row (see [`balance_detail`]), not a second bar. fn snapshot_from_parts( - balance: f64, - currency: String, - cash_balance: Option, - gift_balance: Option, detail: Option, usage: Option, ) -> UsageSnapshot { @@ -224,7 +224,8 @@ fn snapshot_from_parts( let plan_name = detail_data .as_ref() - .and_then(|data| data.plan_code.clone()) + .and_then(|data| data.plan_code.as_deref()) + .map(capitalize_words) .filter(|plan| !plan.trim().is_empty()); let period_end = detail_data .as_ref() @@ -238,8 +239,13 @@ fn snapshot_from_parts( (item.percent * 100.0).clamp(0.0, 100.0), RateWindow::monthly_window_minutes(period_end), period_end, - Some(format!("{}/{} tokens", item.used, item.limit)), + Some(format!( + "{} / {} Credits", + grouped_integer(item.used), + grouped_integer(item.limit) + )), ) + .with_description_as_detail() } else { RateWindow::with_details( 0.0, @@ -248,21 +254,12 @@ fn snapshot_from_parts( Some("No token-plan usage".into()), ) }; - let mut secondary = RateWindow::new(0.0); - secondary.reset_description = Some(balance_description( - balance, - ¤cy, - cash_balance.as_deref(), - gift_balance.as_deref(), - )); - - let mut snapshot = UsageSnapshot::new(primary).with_secondary(secondary); - if let Some(plan) = plan_name { - snapshot = snapshot.with_login_method(plan); - } else { - snapshot = snapshot.with_login_method(format!("{balance:.2} {currency}")); + + let snapshot = UsageSnapshot::new(primary); + match plan_name { + Some(plan) => snapshot.with_login_method(plan), + None => snapshot, } - snapshot } fn parse_mimo_date(value: &str) -> Option> { @@ -275,21 +272,104 @@ fn parse_decimal(value: Option<&str>) -> Option { value?.trim().parse().ok() } -fn balance_description( +/// Upstream `MiMoUsageSnapshot.balanceDetail`, shown as the "Balance" row of a +/// "Credits" section: "CN¥12.34 (Paid: CN¥8.00 / Granted: CN¥4.34)". +fn balance_text( balance: f64, currency: &str, cash_balance: Option<&str>, gift_balance: Option<&str>, ) -> String { let currency = currency.trim(); - let total = format!("{balance:.2} {currency} balance"); + let total = currency_string(balance, currency); let Some(cash) = parse_decimal(cash_balance) else { return total; }; let Some(gift) = parse_decimal(gift_balance) else { return total; }; - format!("{total} (Paid: {cash:.2} {currency} / Granted: {gift:.2} {currency})") + format!( + "{total} (Paid: {} / Granted: {})", + currency_string(cash, currency), + currency_string(gift, currency) + ) +} + +fn balance_detail( + balance: f64, + currency: &str, + cash_balance: Option<&str>, + gift_balance: Option<&str>, +) -> Option { + ProviderDisplayDetail::new( + BALANCE_DETAIL_ID, + "Balance", + balance_text(balance, currency, cash_balance, gift_balance), + ) + .and_then(|row| row.with_section_title("Credits")) +} + +/// en_US currency text as upstream `UsageFormatter.currencyString` renders it +/// (`.currency(code:).locale(en_US)`): symbol prefix, grouped, two decimals. +/// Kept local to MiMo so the shared formatter keeps its current output. +fn currency_string(value: f64, code: &str) -> String { + let prefix = match code.trim().to_ascii_uppercase().as_str() { + "CNY" => "CN¥".to_string(), + "USD" => "$".to_string(), + "EUR" => "€".to_string(), + "GBP" => "£".to_string(), + "" => String::new(), + other => format!("{other}\u{a0}"), + }; + let fixed = format!("{:.2}", value.abs()); + let (whole, fraction) = fixed.split_once('.').unwrap_or((fixed.as_str(), "00")); + let sign = if value < 0.0 && fixed != "0.00" { + "-" + } else { + "" + }; + format!("{sign}{prefix}{}.{fraction}", group_digits(whole)) +} + +/// en_US decimal grouping ("1,000,000"), as upstream `fullCountString`. +fn grouped_integer(value: i64) -> String { + let digits = group_digits(&value.unsigned_abs().to_string()); + if value < 0 { + format!("-{digits}") + } else { + digits + } +} + +/// Inserts a comma every three digits from the right of an ASCII digit run. +fn group_digits(digits: &str) -> String { + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (index, ch) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + out.push(','); + } + out.push(ch); + } + out +} + +/// Foundation `String.capitalized`: words split on whitespace, first letter +/// upper-cased, the rest lower-cased. +fn capitalize_words(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut at_word_start = true; + for ch in value.chars() { + if ch.is_whitespace() { + at_word_start = true; + out.push(ch); + } else if at_word_start { + at_word_start = false; + out.extend(ch.to_uppercase()); + } else { + out.extend(ch.to_lowercase()); + } + } + out } impl Default for MiMoProvider { @@ -315,10 +395,7 @@ impl Provider for MiMoProvider { Some(cookie) => cookie.to_string(), None => crate::providers::browser_cookie_header(&["platform.xiaomimimo.com"])?, }; - Ok(ProviderFetchResult::new( - self.fetch_web(&cookie).await?, - "web", - )) + self.fetch_web(&cookie).await } SourceMode::OAuth | SourceMode::Cli => { Err(ProviderError::UnsupportedSource(ctx.source_mode)) @@ -346,15 +423,76 @@ mod tests { } #[test] - fn mimo_balance_description_includes_paid_and_granted_components() { + fn mimo_balance_text_includes_paid_and_granted_components() { + assert_eq!( + balance_text(12.5, "CNY", Some("8.25"), Some("4.25")), + "CN¥12.50 (Paid: CN¥8.25 / Granted: CN¥4.25)" + ); + assert_eq!(balance_text(12.5, "CNY", Some("8.25"), None), "CN¥12.50"); + } + + #[test] + fn mimo_currency_string_matches_en_us_currency_style() { + assert_eq!(currency_string(1234.5, "CNY"), "CN¥1,234.50"); + assert_eq!(currency_string(-3.0, "usd"), "-$3.00"); + assert_eq!(currency_string(0.0, " CNY "), "CN¥0.00"); + assert_eq!(currency_string(7.25, "HKD"), "HKD\u{a0}7.25"); + assert_eq!(currency_string(-0.001, "USD"), "$0.00"); + assert_eq!(currency_string(999.999, "USD"), "$1,000.00"); + } + + #[test] + fn mimo_grouped_integer_and_capitalize_follow_foundation() { + assert_eq!(grouped_integer(0), "0"); + assert_eq!(grouped_integer(999), "999"); + assert_eq!(grouped_integer(1_000_000), "1,000,000"); + assert_eq!(grouped_integer(-12_345), "-12,345"); + assert_eq!(capitalize_words("pro"), "Pro"); + assert_eq!(capitalize_words("MAX plan"), "Max Plan"); + } + + #[test] + fn mimo_labels_match_upstream_descriptor() { + let provider = MiMoProvider::new(); + assert_eq!(provider.metadata().session_label, "Credits"); + assert_eq!(provider.metadata().weekly_label, "Window"); + } + + /// The whole Mac-parity MiMo pack (routes.json): one Credits bar with the + /// grouped detail line, plan "Pro", and the balance as a Credits section row. + #[test] + fn mimo_parity_pack_matches_upstream_card_fields() { + let detail: Option = serde_json::from_str( + r#"{"code":0,"data":{"planCode":"pro","currentPeriodEnd":"2026-11-01 00:00:00","expired":false}}"#, + ) + .ok(); + assert!(detail.is_some()); + let snapshot = snapshot_from_parts(detail, usage_response(PACK_USAGE_JSON)); + + assert_eq!(snapshot.primary.used_percent, 37.0); assert_eq!( - balance_description(12.5, "CNY", Some("8.25"), Some("4.25")), - "12.50 CNY balance (Paid: 8.25 CNY / Granted: 4.25 CNY)" + snapshot.primary.reset_description.as_deref(), + Some("370,000 / 1,000,000 Credits") ); + assert!(snapshot.primary.description_is_detail); assert_eq!( - balance_description(12.5, "CNY", Some("8.25"), None), - "12.50 CNY balance" + snapshot.primary.resets_at, + Some(Utc.with_ymd_and_hms(2026, 11, 1, 0, 0, 0).unwrap()) ); + assert!(snapshot.secondary.is_none()); + assert_eq!(snapshot.login_method.as_deref(), Some("Pro")); + + let row = balance_detail(12.34, "CNY", Some("8.00"), Some("4.34")).expect("balance row"); + assert_eq!(row.id(), BALANCE_DETAIL_ID); + assert_eq!(row.section_title(), Some("Credits")); + assert_eq!(row.title(), "Balance"); + assert_eq!(row.value(), "CN¥12.34 (Paid: CN¥8.00 / Granted: CN¥4.34)"); + } + + #[test] + fn mimo_without_plan_code_has_no_login_method() { + let snapshot = snapshot_with_usage(usage_response(PACK_USAGE_JSON)); + assert!(snapshot.login_method.is_none()); } /// `tokenPlan/usage` payload from the Mac-parity MiMo pack (synthetic values). @@ -375,14 +513,7 @@ mod tests { } fn snapshot_with_usage(usage: Option) -> UsageSnapshot { - snapshot_from_parts( - 12.34, - "CNY".into(), - Some("8.00".into()), - Some("4.34".into()), - None, - usage, - ) + snapshot_from_parts(None, usage) } fn usage_item_json(used: i64, limit: i64, percent: f64) -> String { @@ -397,7 +528,7 @@ mod tests { assert_eq!(snapshot.primary.used_percent, 37.0); assert_eq!( snapshot.primary.reset_description.as_deref(), - Some("370000/1000000 tokens") + Some("370,000 / 1,000,000 Credits") ); } From 30c178558e2d2d05273a6b9138358bb9acc7917a Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:25:42 +0700 Subject: [PATCH 2/2] Cover MiMo balance response decoding into the Credits row --- rust/src/providers/mimo/mod.rs | 63 ++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/rust/src/providers/mimo/mod.rs b/rust/src/providers/mimo/mod.rs index b5d5e743c5..7b9429a45e 100644 --- a/rust/src/providers/mimo/mod.rs +++ b/rust/src/providers/mimo/mod.rs @@ -116,21 +116,12 @@ impl MiMoProvider { let data = balance .data .ok_or_else(|| ProviderError::Parse("MiMo balance payload missing".into()))?; - let balance_value = data - .balance - .parse::() - .map_err(|_| ProviderError::Parse("MiMo balance value invalid".into()))?; + let balance_row = balance_row_from_data(&data)?; let detail: Option = self.get_json("tokenPlan/detail", &cookie).await.ok(); let usage: Option = self.get_json("tokenPlan/usage", &cookie).await.ok(); - let balance_row = balance_detail( - balance_value, - &data.currency, - data.cash_balance.as_deref(), - data.gift_balance.as_deref(), - ); let snapshot = snapshot_from_parts(detail, usage); Ok(ProviderFetchResult::new(snapshot, "web").with_display_detail(balance_row)) } @@ -309,6 +300,24 @@ fn balance_detail( .and_then(|row| row.with_section_title("Credits")) } +/// The "Credits" balance row for a decoded `balance` payload, as `fetch_web` +/// builds it. A non-numeric balance is a parse error, reported before any +/// plan or usage request is made. +fn balance_row_from_data( + data: &BalanceData, +) -> Result, ProviderError> { + let balance_value = data + .balance + .parse::() + .map_err(|_| ProviderError::Parse("MiMo balance value invalid".into()))?; + Ok(balance_detail( + balance_value, + &data.currency, + data.cash_balance.as_deref(), + data.gift_balance.as_deref(), + )) +} + /// en_US currency text as upstream `UsageFormatter.currencyString` renders it /// (`.currency(code:).locale(en_US)`): symbol prefix, grouped, two decimals. /// Kept local to MiMo so the shared formatter keeps its current output. @@ -489,6 +498,40 @@ mod tests { assert_eq!(row.value(), "CN¥12.34 (Paid: CN¥8.00 / Granted: CN¥4.34)"); } + /// A `balance` response decoded into `BalanceResponse` (synthetic values from + /// the Mac-parity MiMo pack) yields the same "Credits" row `fetch_web` builds. + #[test] + fn mimo_balance_response_decodes_into_credits_row() { + let balance: BalanceResponse = serde_json::from_str( + r#"{"code":0,"data":{"balance":"12.34","currency":"CNY","cashBalance":"8.00","giftBalance":"4.34"}}"#, + ) + .expect("balance response decodes"); + assert_eq!(balance.code, 0); + let data = balance.data.expect("balance payload"); + + let row = balance_row_from_data(&data) + .expect("balance row builds") + .expect("balance row present"); + assert_eq!(row.id(), BALANCE_DETAIL_ID); + assert_eq!(row.title(), "Balance"); + assert_eq!(row.section_title(), Some("Credits")); + assert_eq!(row.value(), "CN¥12.34 (Paid: CN¥8.00 / Granted: CN¥4.34)"); + } + + #[test] + fn mimo_non_numeric_balance_is_a_parse_error() { + let data = BalanceData { + balance: "twelve".to_string(), + currency: "CNY".to_string(), + cash_balance: None, + gift_balance: None, + }; + assert!(matches!( + balance_row_from_data(&data), + Err(ProviderError::Parse(_)) + )); + } + #[test] fn mimo_without_plan_code_has_no_login_method() { let snapshot = snapshot_with_usage(usage_response(PACK_USAGE_JSON));