diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-coderabbit.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-coderabbit.svg new file mode 100644 index 0000000000..da00467579 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-coderabbit.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index f4890d63ed..955ed3a941 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -10,6 +10,7 @@ import augment from "./icons/ProviderIcon-augment.svg?raw"; import bedrock from "./icons/ProviderIcon-bedrock.svg?raw"; import claude from "./icons/ProviderIcon-claude.svg?raw"; import codebuff from "./icons/ProviderIcon-codebuff.svg?raw"; +import coderabbit from "./icons/ProviderIcon-coderabbit.svg?raw"; import codex from "./icons/ProviderIcon-codex.svg?raw"; import commandcode from "./icons/ProviderIcon-commandcode.svg?raw"; import copilot from "./icons/ProviderIcon-copilot.svg?raw"; @@ -94,6 +95,7 @@ const RAW: Record = { bedrock: tint(bedrock), claude: tint(claude), codebuff: tint(codebuff), + coderabbit: tint(coderabbit), codex: tint(codex), commandcode: tint(commandcode), copilot: tint(copilot), @@ -159,6 +161,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { augment: { id: "augment", brandColor: "#6366f1", fallbackLetter: "A", svgPath: RAW.augment }, claude: { id: "claude", brandColor: "#cc7c5e", fallbackLetter: "◈", svgPath: RAW.claude }, codebuff: { id: "codebuff", brandColor: "#44ff00", fallbackLetter: "B", svgPath: RAW.codebuff }, + coderabbit: { id: "coderabbit", brandColor: "#ff5c35", fallbackLetter: "C", svgPath: RAW.coderabbit }, codex: { id: "codex", brandColor: "#49a3b0", fallbackLetter: "◆", svgPath: RAW.codex }, copilot: { id: "copilot", brandColor: "#a855f7", fallbackLetter: "⬡", svgPath: RAW.copilot }, cursor: { id: "cursor", brandColor: "#00bfa5", fallbackLetter: "▸", svgPath: RAW.cursor }, diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 3186dfa938..75f71c8e28 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -32,6 +32,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["kilo", "Kilo"], ["bedrock", "AWS Bedrock"], ["codebuff", "Codebuff"], + ["coderabbit", "CodeRabbit"], ["deepseek", "DeepSeek"], ["deepinfra", "DeepInfra"], ["fireworks", "Fireworks"], diff --git a/rust/src/cli/serve/dashboard/icons.rs b/rust/src/cli/serve/dashboard/icons.rs index 3b1274ffef..c1896fcbb4 100644 --- a/rust/src/cli/serve/dashboard/icons.rs +++ b/rust/src/cli/serve/dashboard/icons.rs @@ -105,6 +105,10 @@ static ICONS: &[(&str, &[u8])] = &[ "ProviderIcon-codebuff", include_bytes!("icons/ProviderIcon-codebuff.svg"), ), + ( + "ProviderIcon-coderabbit", + include_bytes!("icons/ProviderIcon-coderabbit.svg"), + ), ( "ProviderIcon-codex", include_bytes!("icons/ProviderIcon-codex.svg"), diff --git a/rust/src/cli/serve/dashboard/icons/ProviderIcon-coderabbit.svg b/rust/src/cli/serve/dashboard/icons/ProviderIcon-coderabbit.svg new file mode 100644 index 0000000000..da00467579 --- /dev/null +++ b/rust/src/cli/serve/dashboard/icons/ProviderIcon-coderabbit.svg @@ -0,0 +1,3 @@ + + + diff --git a/rust/src/cli/usage_tests.rs b/rust/src/cli/usage_tests.rs index 25428709c7..d332131b81 100644 --- a/rust/src/cli/usage_tests.rs +++ b/rust/src/cli/usage_tests.rs @@ -9,7 +9,7 @@ use crate::providers::claude::claude_swap::ClaudeSwapAccount; use crate::status::{ProviderStatus as StatusInfo, StatusLevel}; use chrono::Utc; use fetch_helpers::find_token_account; -use render::render_json_result; +use render::{render_json_result, render_text_with_status}; fn fetch_result(usage: UsageSnapshot) -> ProviderFetchResult { ProviderFetchResult::new(usage, "test") diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 3af8d4bfb7..444462c677 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -45,6 +45,7 @@ pub enum ProviderId { Kilo, Bedrock, Codebuff, + CodeRabbit, DeepSeek, DeepInfra, AiAnd, @@ -124,6 +125,7 @@ impl ProviderId { ProviderId::Kilo, ProviderId::Bedrock, ProviderId::Codebuff, + ProviderId::CodeRabbit, ProviderId::DeepSeek, ProviderId::DeepInfra, ProviderId::AiAnd, @@ -202,6 +204,7 @@ impl ProviderId { ProviderId::Kilo => "kilo", ProviderId::Bedrock => "bedrock", ProviderId::Codebuff => "codebuff", + ProviderId::CodeRabbit => "coderabbit", ProviderId::DeepSeek => "deepseek", ProviderId::DeepInfra => "deepinfra", ProviderId::Fireworks => "fireworks", @@ -281,6 +284,7 @@ impl ProviderId { ProviderId::Kilo => "Kilo", ProviderId::Bedrock => "AWS Bedrock", ProviderId::Codebuff => "Codebuff", + ProviderId::CodeRabbit => "CodeRabbit", ProviderId::DeepSeek => "DeepSeek", ProviderId::DeepInfra => "DeepInfra", ProviderId::Fireworks => "Fireworks", @@ -373,6 +377,7 @@ impl ProviderId { ProviderId::Kilo => None, ProviderId::Bedrock => None, ProviderId::Codebuff => None, + ProviderId::CodeRabbit => None, ProviderId::DeepSeek => None, ProviderId::DeepInfra => None, ProviderId::Fireworks => None, @@ -448,6 +453,7 @@ impl ProviderId { "kilo" => Some(ProviderId::Kilo), "bedrock" | "aws-bedrock" | "aws bedrock" => Some(ProviderId::Bedrock), "codebuff" | "manicode" => Some(ProviderId::Codebuff), + "coderabbit" | "code-rabbit" | "code rabbit" => Some(ProviderId::CodeRabbit), "deepseek" | "deep-seek" | "ds" => Some(ProviderId::DeepSeek), "deepinfra" | "deep-infra" | "di" => Some(ProviderId::DeepInfra), "fireworks" | "fireworks-ai" | "fw" => Some(ProviderId::Fireworks), @@ -972,6 +978,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Kilo => "#5D87FF", ProviderId::Bedrock => "#FF9900", ProviderId::Codebuff => "#44FF00", + ProviderId::CodeRabbit => "#FF5C35", ProviderId::DeepSeek => "#527DF0", ProviderId::DeepInfra => "#2A3275", ProviderId::AiAnd => "#E25C2B", @@ -1025,7 +1032,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 72); + assert_eq!(all.len(), 73); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Fireworks)); @@ -1040,6 +1047,7 @@ mod tests { assert!(all.contains(&ProviderId::Infini)); assert!(all.contains(&ProviderId::Bedrock)); assert!(all.contains(&ProviderId::Codebuff)); + assert!(all.contains(&ProviderId::CodeRabbit)); assert!(all.contains(&ProviderId::DeepSeek)); assert!(all.contains(&ProviderId::DeepInfra)); assert!(all.contains(&ProviderId::AiAnd)); @@ -1113,6 +1121,7 @@ mod tests { assert_eq!(ProviderId::Codex.cli_name(), "codex"); assert_eq!(ProviderId::Factory.cli_name(), "factory"); assert_eq!(ProviderId::Zai.cli_name(), "zai"); + assert_eq!(ProviderId::CodeRabbit.cli_name(), "coderabbit"); } #[test] @@ -1120,6 +1129,7 @@ mod tests { assert_eq!(ProviderId::Claude.display_name(), "Claude"); assert_eq!(ProviderId::Factory.display_name(), "Factory"); assert_eq!(ProviderId::Zai.display_name(), "z.ai"); + assert_eq!(ProviderId::CodeRabbit.display_name(), "CodeRabbit"); } #[test] @@ -1148,6 +1158,10 @@ mod tests { ); assert_eq!(ProviderId::from_cli_name("zed"), Some(ProviderId::Zed)); assert_eq!(ProviderId::from_cli_name("unknown"), None); + assert_eq!( + ProviderId::from_cli_name("code-rabbit"), + Some(ProviderId::CodeRabbit) + ); } #[test] @@ -1219,6 +1233,7 @@ mod tests { assert_eq!(ProviderId::Zai.cookie_domain(), None); assert_eq!(ProviderId::VertexAI.cookie_domain(), None); assert_eq!(ProviderId::JetBrains.cookie_domain(), None); + assert_eq!(ProviderId::CodeRabbit.cookie_domain(), None); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 116e536048..4d495b0f06 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -9,13 +9,13 @@ use super::{Provider, ProviderId}; use crate::providers::{ AbacusProvider, AiAndProvider, AlibabaProvider, AlibabaTokenPlanProvider, AmpProvider, AntigravityProvider, AugmentProvider, AzureOpenAIProvider, BedrockProvider, ChutesProvider, - ClaudeProvider, ClinePassProvider, CodeBuddyProvider, CodebuffProvider, CodexProvider, - CommandCodeProvider, CopilotProvider, CrofProvider, CrossModelProvider, CursorProvider, - DeepInfraProvider, DeepSeekProvider, DeepgramProvider, DevinProvider, DoubaoProvider, - ElevenLabsProvider, FactoryProvider, FireworksProvider, GeminiProvider, GrokProvider, - GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider, KimiProvider, - KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, MetaProvider, - MiMoProvider, MiniMaxProvider, MistralProvider, MuseProvider, NanoGPTProvider, + ClaudeProvider, ClinePassProvider, CodeBuddyProvider, CodeRabbitProvider, CodebuffProvider, + CodexProvider, CommandCodeProvider, CopilotProvider, CrofProvider, CrossModelProvider, + CursorProvider, DeepInfraProvider, DeepSeekProvider, DeepgramProvider, DevinProvider, + DoubaoProvider, ElevenLabsProvider, FactoryProvider, FireworksProvider, GeminiProvider, + GrokProvider, GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider, + KimiProvider, KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, + MetaProvider, MiMoProvider, MiniMaxProvider, MistralProvider, MuseProvider, NanoGPTProvider, NeuralwattProvider, NotionProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, QwenCloudProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, @@ -62,6 +62,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Kilo => Box::new(KiloProvider::new()), ProviderId::Bedrock => Box::new(BedrockProvider::new()), ProviderId::Codebuff => Box::new(CodebuffProvider::new()), + ProviderId::CodeRabbit => Box::new(CodeRabbitProvider::new()), ProviderId::DeepSeek => Box::new(DeepSeekProvider::new()), ProviderId::DeepInfra => Box::new(DeepInfraProvider::new()), ProviderId::AiAnd => Box::new(AiAndProvider::new()), diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 694a9d65ef..f913f8bcf9 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -331,6 +331,7 @@ impl TokenAccountSupport { | ProviderId::Kilo | ProviderId::Bedrock | ProviderId::Codebuff + | ProviderId::CodeRabbit | ProviderId::DeepSeek | ProviderId::Windsurf | ProviderId::Doubao diff --git a/rust/src/providers/coderabbit/mod.rs b/rust/src/providers/coderabbit/mod.rs new file mode 100644 index 0000000000..383e468851 --- /dev/null +++ b/rust/src/providers/coderabbit/mod.rs @@ -0,0 +1,574 @@ +//! CodeRabbit provider implementation. +//! +//! CodeRabbit exposes its usage through the local `coderabbit usage` command. +//! This provider deliberately keeps that boundary local: it does not inspect +//! browser state, make network requests, or persist the command output. + +use async_trait::async_trait; +use std::collections::HashSet; +use std::process::Stdio; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command; + +use crate::core::{ + FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, +}; + +const CLI_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_OUTPUT_BYTES: usize = 128 * 1024; +const MAX_FIELD_CHARS: usize = 512; +const DEFAULT_PROGRAM: &str = "coderabbit"; +const PROGRAM_OVERRIDE_ENV: &str = "CODERABBIT_CLI_PATH"; + +/// Single message for the shared 128 KiB output cap across reader boundaries. +const OUTPUT_BUDGET_EXCEEDED: &str = "CodeRabbit CLI output exceeded 128 KiB."; + +/// CREATE_NO_WINDOW for `std::os::windows::process::CommandExt::creation_flags`. +/// Keeps the CLI's console window hidden when the fetch runs from a GUI process. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct CodeRabbitUsage { + organization: Option, + user: Option, + plan: Option, + reviews: Option, + usage_billing: Option, + period_resets: Option, +} + +#[derive(Debug)] +struct CliOutput { + status: std::process::ExitStatus, + stdout: Vec, + stderr: Vec, +} + +pub struct CodeRabbitProvider { + metadata: ProviderMetadata, +} + +impl CodeRabbitProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::CodeRabbit, + display_name: "CodeRabbit", + session_label: "Reviews", + weekly_label: "Billing", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://app.coderabbit.ai"), + status_page_url: Some("https://status.coderabbit.ai"), + tertiary_label_key: None, + }, + } + } +} + +impl Default for CodeRabbitProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for CodeRabbitProvider { + fn id(&self) -> ProviderId { + ProviderId::CodeRabbit + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::Cli => { + let output = run_cli(&configured_program()).await?; + let mut combined = output.stdout; + combined.push(b'\n'); + combined.extend_from_slice(&output.stderr); + let combined = decode_cli_output(&combined)?; + + if let Some(issues) = decode_control_character_issue(combined) { + return Err(issues); + } + + if !output.status.success() { + if looks_signed_out(combined) { + return Err(ProviderError::AuthRequired); + } + return Err(ProviderError::Other(format!( + "CodeRabbit CLI failed with exit code {}", + output + .status + .code() + .map_or_else(|| "unknown".to_string(), |code| code.to_string()) + ))); + } + + let usage = parse_usage(combined)?; + Ok(fetch_result(&usage)) + } + SourceMode::OAuth | SourceMode::Web => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::Cli] + } + + fn supports_cli(&self) -> bool { + true + } +} + +fn configured_program() -> String { + std::env::var(PROGRAM_OVERRIDE_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| { + which::which(DEFAULT_PROGRAM) + .ok() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|| DEFAULT_PROGRAM.to_string()) + }) +} + +async fn run_cli(program: &str) -> Result { + let mut command = Command::new(program); + command + .arg("usage") + .env("NO_COLOR", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + let mut child = command.spawn().map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + ProviderError::NotInstalled( + "CodeRabbit CLI not found. Install it or set CODERABBIT_CLI_PATH.".to_string(), + ) + } else { + ProviderError::Other("CodeRabbit CLI could not be started.".to_string()) + } + })?; + + let stdout = child.stdout.take().ok_or_else(|| { + ProviderError::Other("CodeRabbit CLI stdout was unavailable.".to_string()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + ProviderError::Other("CodeRabbit CLI stderr was unavailable.".to_string()) + })?; + + let budget = AtomicBudget::default(); + let combined = tokio::time::timeout(CLI_TIMEOUT, async { + let (stdout, stderr, status) = tokio::join!( + read_bounded(stdout, &budget), + read_bounded(stderr, &budget), + child.wait(), + ); + + let mut combined = stdout?; + let stderr = stderr?; + combined.push(b'\n'); + combined.extend_from_slice(&stderr); + + let status = status + .map_err(|_| ProviderError::Other("CodeRabbit CLI process wait failed.".to_string()))?; + + Ok(CliOutput { + status, + stdout: combined, + stderr: Vec::new(), + }) + }) + .await; + + match combined { + Ok(result) => result, + Err(_) => { + drop(child.kill().await); + drop(child.wait().await); + Err(ProviderError::Timeout) + } + } +} + +/// Shared byte budget for the two bounded stream readers. Both streams write +/// into one buffer, so the cap applies to their combined size. +#[derive(Default)] +struct AtomicBudget(AtomicUsize); + +impl AtomicBudget { + fn reserve(&self, count: usize) -> Result<(), ProviderError> { + let mut current = self.0.load(Ordering::Relaxed); + loop { + let next = current + .checked_add(count) + .filter(|next| *next <= MAX_OUTPUT_BYTES); + let Some(next) = next else { + return Err(ProviderError::Other(OUTPUT_BUDGET_EXCEEDED.to_string())); + }; + match self + .0 + .compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) + { + Ok(_) => return Ok(()), + Err(actual) => current = actual, + } + } + } +} + +fn decode_cli_output(bytes: &[u8]) -> Result<&str, ProviderError> { + std::str::from_utf8(bytes).map_err(|_| { + ProviderError::Parse("CodeRabbit CLI returned invalid UTF-8 output.".to_string()) + }) +} + +/// Boundary check for control characters outside newline/CR. It validates the +/// whole decoded payload once, before any field parsing; per-field rules stay +/// inside `parse_usage`. +fn decode_control_character_issue(text: &str) -> Option { + let has_control = text + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r')); + has_control + .then(|| ProviderError::Parse("CodeRabbit CLI returned an invalid field.".to_string())) +} + +async fn read_bounded( + reader: R, + budget: &AtomicBudget, +) -> Result, ProviderError> { + let mut reader = reader; + let mut output = Vec::new(); + let mut buffer = [0_u8; 8192]; + loop { + let count = reader.read(&mut buffer).await.map_err(|_| { + ProviderError::Other("CodeRabbit CLI output could not be read.".to_string()) + })?; + if count == 0 { + return Ok(output); + } + + budget.reserve(count)?; + output.extend_from_slice(&buffer[..count]); + } +} + +fn parse_usage(text: &str) -> Result { + if looks_signed_out(text) { + return Err(ProviderError::AuthRequired); + } + + let mut usage = CodeRabbitUsage::default(); + let mut seen: HashSet<&str> = HashSet::new(); + for line in text.lines() { + let Some((label, raw_value)) = line.split_once(':') else { + continue; + }; + let label = label.trim().to_ascii_lowercase(); + let value = raw_value.trim(); + if value.chars().count() > MAX_FIELD_CHARS || value.chars().any(char::is_control) { + return Err(ProviderError::Parse( + "CodeRabbit CLI returned an invalid field.".to_string(), + )); + } + + match label.as_str() { + "organization" if !seen.contains("organization") => { + seen.insert("organization"); + if !value.is_empty() { + usage.organization = Some(value.to_string()); + } + } + "user" if !seen.contains("user") => { + seen.insert("user"); + if !value.is_empty() { + usage.user = Some(value.to_string()); + } + } + "plan" if !seen.contains("plan") => { + seen.insert("plan"); + if !value.is_empty() { + usage.plan = Some(value.to_string()); + } + } + "your reviews" if !seen.contains("reviews") => { + seen.insert("reviews"); + usage.reviews = Some(value.parse::().map_err(|_| { + ProviderError::Parse( + "CodeRabbit CLI returned an invalid review count.".to_string(), + ) + })?); + } + "usage billing" if !seen.contains("billing") => { + seen.insert("billing"); + if !value.is_empty() { + usage.usage_billing = Some(value.to_string()); + } + } + "period resets" if !seen.contains("resets") => { + seen.insert("resets"); + if !value.is_empty() { + usage.period_resets = Some(value.to_string()); + } + } + _ => {} + } + } + + if usage.reviews.is_none() && usage.usage_billing.is_none() { + return Err(ProviderError::Parse( + "CodeRabbit CLI returned no usage fields.".to_string(), + )); + } + + Ok(usage) +} + +fn fetch_result(usage: &CodeRabbitUsage) -> ProviderFetchResult { + let detail = |id: &'static str, title: &'static str, value: Option| { + value.map(|value| ProviderDisplayDetail::new(id, title, value)) + }; + let details = [ + detail( + "organization", + "Organization", + usage.organization.clone().filter(|value| !value.is_empty()), + ), + detail( + "user", + "User", + usage.user.clone().filter(|value| !value.is_empty()), + ), + detail( + "plan", + "Plan", + usage.plan.clone().filter(|value| !value.is_empty()), + ), + usage + .reviews + .map(|value| ProviderDisplayDetail::new("reviews", "Reviews", value.to_string())), + detail( + "usage-billing", + "Usage billing", + usage + .usage_billing + .clone() + .filter(|value| !value.is_empty()), + ), + detail( + "period-resets", + "Period resets", + usage + .period_resets + .clone() + .filter(|value| !value.is_empty()), + ), + ]; + + let base = ProviderFetchResult::new( + UsageSnapshot::new(RateWindow::informational("CodeRabbit CLI")) + .with_primary_label("Reviews"), + "cli", + ) + .with_non_authoritative_pace(); + + details + .into_iter() + .flatten() + .fold(base, |result, detail| result.with_display_detail(detail)) +} + +/// Matches human-readable sign-out markers in CLI text. This is a heuristic: +/// CodeRabbit's CLI currently exposes no deterministic signed-out signal +/// (no dedicated exit code or structured auth field in `coderabbit usage` +/// output), so marker phrases are the only available detection channel. If a +/// deterministic signal appears upstream, prefer it and retire these phrases. +fn looks_signed_out(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + [ + "not authenticated", + "please log in", + "authentication required", + "unauthorized", + "no session found", + ] + .iter() + .any(|marker| lower.contains(marker)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncWriteExt, duplex}; + + #[test] + fn parses_supported_fields_with_first_value_wins() { + let usage = parse_usage( + "Organization: acme\nUser: ness\nPlan: Pro\nYour reviews: 42\nYour reviews: 99\nUsage billing: Included\nPeriod resets: Friday", + ) + .expect("valid CodeRabbit output"); + + assert_eq!(usage.organization.as_deref(), Some("acme")); + assert_eq!(usage.user.as_deref(), Some("ness")); + assert_eq!(usage.plan.as_deref(), Some("Pro")); + assert_eq!(usage.reviews, Some(42)); + assert_eq!(usage.usage_billing.as_deref(), Some("Included")); + assert_eq!(usage.period_resets.as_deref(), Some("Friday")); + } + + #[test] + fn rejects_control_sequences_and_signed_out_output_even_with_usage() { + assert!(matches!( + decode_control_character_issue("\u{1b}[32mYour reviews: 3\u{1b}[0m"), + Some(ProviderError::Parse(_)) + )); + assert!(decode_control_character_issue("Your reviews: 3\nok").is_none()); + + assert!(matches!( + parse_usage("Your reviews: 3\nPlease log in with auth login"), + Err(ProviderError::AuthRequired) + )); + assert!(matches!( + parse_usage("status: ready"), + Err(ProviderError::Parse(_)) + )); + } + + #[test] + fn rejects_invalid_review_counts_and_requires_usage_structure() { + assert!(matches!( + parse_usage("Your reviews: -1"), + Err(ProviderError::Parse(_)) + )); + assert!(matches!( + parse_usage("Period resets: tomorrow"), + Err(ProviderError::Parse(_)) + )); + let usage = parse_usage("Your reviews: 0").unwrap(); + assert_eq!(usage.reviews, Some(0)); + } + + #[test] + fn rejects_control_or_oversized_fields_before_display_projection() { + assert!(matches!( + parse_usage("Plan: bad\u{1b}[31m"), + Err(ProviderError::Parse(_)) + )); + let oversized = format!("Plan: {}\nYour reviews: 1", "x".repeat(MAX_FIELD_CHARS + 1)); + assert!(matches!( + parse_usage(&oversized), + Err(ProviderError::Parse(_)) + )); + } + + #[test] + fn rejects_invalid_utf8_before_parsing() { + assert!(matches!( + decode_cli_output(b"Your reviews: 3\xff"), + Err(ProviderError::Parse(_)) + )); + } + + #[test] + fn malformed_first_review_value_cannot_be_replaced_by_a_later_duplicate() { + assert!(matches!( + parse_usage("Your reviews: unknown\nYour reviews: 3\nUsage billing: Included"), + Err(ProviderError::Parse(_)) + )); + } + + #[test] + fn help_text_can_mention_login_without_marking_a_valid_report_signed_out() { + let usage = + parse_usage("Your reviews: 3\nTo switch accounts, run coderabbit auth login.").unwrap(); + assert_eq!(usage.reviews, Some(3)); + } + + #[test] + fn configured_path_override_is_trimmed_without_shell_parsing() { + // Same trim/filter contract the collapsed `configured_program` applies + // to `CODERABBIT_CLI_PATH` before the `which()` fallback. + let trimmed = |value: Option<&str>| { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }; + assert_eq!( + trimmed(Some(" C:\\Tools\\coderabbit.exe ")), + Some("C:\\Tools\\coderabbit.exe".to_string()) + ); + assert_eq!(trimmed(Some(" ")), None); + assert_eq!(trimmed(None), None); + } + + #[tokio::test] + async fn bounded_reader_rejects_combined_output_budget() { + let (mut writer, mut reader) = duplex(MAX_OUTPUT_BYTES + 1); + let writer_task = tokio::spawn(async move { + let bytes = vec![b'x'; MAX_OUTPUT_BYTES + 1]; + drop(writer.write_all(&bytes).await); + }); + let budget = AtomicBudget::default(); + let result = read_bounded(&mut reader, &budget).await; + assert!(matches!(result, Err(ProviderError::Other(_)))); + drop(writer_task.await); + } + + #[test] + fn result_keeps_details_transient_and_does_not_set_identity() { + let usage = parse_usage( + "Organization: acme\nUser: ness@example.test\nPlan: Pro\nYour reviews: 4\nUsage billing: Included\nPeriod resets: Friday", + ) + .unwrap(); + let result = fetch_result(&usage); + let detail_ids = result + .display_details() + .iter() + .map(|detail| detail.id().to_string()) + .collect::>(); + assert_eq!( + detail_ids, + vec![ + "organization", + "user", + "plan", + "reviews", + "usage-billing", + "period-resets", + ] + ); + assert!(result.account_identity().is_none()); + assert!(result.usage.account_organization.is_none()); + assert!(result.usage.login_method.is_none()); + let encoded = serde_json::to_value(result).unwrap(); + assert!(encoded.get("display_details").is_none()); + } + + #[test] + fn provider_is_cli_only_and_disabled_by_default() { + let provider = CodeRabbitProvider::new(); + assert_eq!(provider.id(), ProviderId::CodeRabbit); + assert_eq!( + provider.available_sources(), + vec![SourceMode::Auto, SourceMode::Cli] + ); + assert!(provider.supports_cli()); + assert!(!provider.metadata().default_enabled); + } +} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index a1d0c7a666..335bc240f2 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -19,6 +19,7 @@ pub mod claude; pub mod clinepass; pub mod codebuddy; pub mod codebuff; +pub mod coderabbit; pub mod codex; pub mod commandcode; pub mod copilot; @@ -94,6 +95,7 @@ pub use claude::ClaudeProvider; pub use clinepass::ClinePassProvider; pub use codebuddy::CodeBuddyProvider; pub use codebuff::CodebuffProvider; +pub use coderabbit::CodeRabbitProvider; pub use codex::CodexProvider; pub use commandcode::CommandCodeProvider; pub use copilot::CopilotProvider;