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;