1use std::collections::BTreeMap;
6
7use super::rate_limit::config::{
8 apply_common_extras, parse_extra_usage_format, CommonRateLimitConfig, ExtraUsageFormat,
9 PRIORITY,
10};
11use super::rate_limit::format::{format_extra_usage, render_error};
12use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
13use crate::data_context::{DataContext, DataDep, UsageData};
14use crate::theme::Role;
15
16#[non_exhaustive]
17pub struct ExtraUsageSegment {
18 pub format: ExtraUsageFormat,
19 pub config: CommonRateLimitConfig,
20}
21
22impl Default for ExtraUsageSegment {
23 fn default() -> Self {
24 Self {
25 format: ExtraUsageFormat::Currency,
26 config: CommonRateLimitConfig::new("extra"),
27 }
28 }
29}
30
31impl ExtraUsageSegment {
32 #[must_use]
33 pub fn from_extras(
34 extras: &BTreeMap<String, toml::Value>,
35 warn: &mut impl FnMut(&str),
36 ) -> Self {
37 let mut seg = Self::default();
38 apply_common_extras(&mut seg.config, extras, "extra_usage", warn);
39 if let Some(f) = parse_extra_usage_format(extras, "extra_usage", warn) {
40 seg.format = f;
41 }
42 seg
43 }
44}
45
46impl Segment for ExtraUsageSegment {
47 fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
48 let usage = ctx.usage();
49 match &*usage {
50 Ok(UsageData::Endpoint(e)) => {
51 let Some(extra) = e.extra_usage.as_ref() else {
52 crate::lsm_debug!("extra_usage: endpoint extra_usage absent; hiding");
53 return Ok(None);
54 };
55 if !extra.is_enabled.unwrap_or(false) {
59 crate::lsm_debug!("extra_usage: extra_usage.is_enabled = false/absent; hiding");
60 return Ok(None);
61 }
62 match format_extra_usage(extra, self.format, &self.config) {
63 Some(text) => Ok(Some(RenderedSegment::new(text).with_role(Role::Info))),
64 None => {
65 crate::lsm_debug!(
66 "extra_usage: format_extra_usage returned None (missing cost or format suppressed); hiding"
67 );
68 Ok(None)
69 }
70 }
71 }
72 Ok(UsageData::Jsonl(_)) => {
75 crate::lsm_debug!("extra_usage: jsonl fallback has no overage data; hiding");
76 Ok(None)
77 }
78 Err(err) => {
79 let text = render_error(err, &self.config);
84 Ok(Some(RenderedSegment::new(text).with_role(Role::Info)))
85 }
86 }
87 }
88
89 fn data_deps(&self) -> &'static [DataDep] {
90 &[DataDep::Usage]
91 }
92
93 fn defaults(&self) -> SegmentDefaults {
94 SegmentDefaults::with_priority(PRIORITY)
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use crate::data_context::{
102 EndpointUsage, ExtraUsage, JsonlUsage, SevenDayWindow, TokenCounts, UsageData, UsageError,
103 };
104 use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
105 use std::path::PathBuf;
106 use std::sync::Arc;
107
108 fn rc() -> RenderContext {
109 RenderContext::new(80)
110 }
111
112 fn ctx_with_usage(usage: Result<UsageData, UsageError>) -> DataContext {
113 let dc = DataContext::new(StatusContext {
114 tool: Tool::ClaudeCode,
115 model: Some(ModelInfo {
116 display_name: "X".into(),
117 }),
118 workspace: Some(WorkspaceInfo {
119 project_dir: PathBuf::from("/repo"),
120 git_worktree: None,
121 }),
122 context_window: None,
123 cost: None,
124 effort: None,
125 vim: None,
126 output_style: None,
127 agent_name: None,
128 version: None,
129 raw: Arc::new(serde_json::Value::Null),
130 });
131 dc.preseed_usage(usage).expect("seed");
132 dc
133 }
134
135 fn data_with_extra(extra: Option<ExtraUsage>) -> UsageData {
136 UsageData::Endpoint(EndpointUsage {
137 five_hour: None,
138 seven_day: None,
139 seven_day_opus: None,
140 seven_day_sonnet: None,
141 seven_day_oauth_apps: None,
142 extra_usage: extra,
143 unknown_buckets: std::collections::HashMap::new(),
144 })
145 }
146
147 fn enabled_extra(limit: Option<f64>, used: Option<f64>) -> ExtraUsage {
148 ExtraUsage {
149 is_enabled: Some(true),
150 utilization: None,
151 monthly_limit: limit,
152 used_credits: used,
153 currency: Some("USD".into()),
154 }
155 }
156
157 #[test]
158 fn hidden_when_extra_usage_missing() {
159 let dc = ctx_with_usage(Ok(data_with_extra(None)));
160 assert_eq!(
161 ExtraUsageSegment::default().render(&dc, &rc()).unwrap(),
162 None
163 );
164 }
165
166 #[test]
167 fn hidden_when_is_enabled_false() {
168 let extra = ExtraUsage {
169 is_enabled: Some(false),
170 utilization: None,
171 monthly_limit: Some(100.0),
172 used_credits: Some(40.0),
173 currency: Some("USD".into()),
174 };
175 let dc = ctx_with_usage(Ok(data_with_extra(Some(extra))));
176 assert_eq!(
177 ExtraUsageSegment::default().render(&dc, &rc()).unwrap(),
178 None
179 );
180 }
181
182 #[test]
183 fn renders_remaining_credits_in_currency_format() {
184 let dc = ctx_with_usage(Ok(data_with_extra(Some(enabled_extra(
185 Some(100.0),
186 Some(40.0),
187 )))));
188 let rendered = ExtraUsageSegment::default()
189 .render(&dc, &rc())
190 .unwrap()
191 .expect("visible");
192 assert_eq!(rendered.text(), "extra: $60.00");
193 }
194
195 #[test]
196 fn non_usd_currency_renders_iso_code_prefix() {
197 let extra = ExtraUsage {
198 is_enabled: Some(true),
199 utilization: None,
200 monthly_limit: Some(100.0),
201 used_credits: Some(40.0),
202 currency: Some("EUR".into()),
203 };
204 let dc = ctx_with_usage(Ok(data_with_extra(Some(extra))));
205 let rendered = ExtraUsageSegment::default()
206 .render(&dc, &rc())
207 .unwrap()
208 .expect("visible");
209 assert_eq!(rendered.text(), "extra: EUR 60.00");
210 }
211
212 #[test]
213 fn renders_error_instead_of_hiding_when_fetch_fails() {
214 let dc = ctx_with_usage(Err(UsageError::Timeout));
215 let rendered = ExtraUsageSegment::default()
216 .render(&dc, &rc())
217 .unwrap()
218 .expect("visible");
219 assert_eq!(rendered.text(), "extra: [Timeout]");
220 }
221
222 #[test]
223 fn declares_usage_as_its_only_data_dep() {
224 assert_eq!(ExtraUsageSegment::default().data_deps(), &[DataDep::Usage],);
225 }
226
227 #[test]
228 fn hidden_under_jsonl_fallback() {
229 let data = UsageData::Jsonl(JsonlUsage::new(
234 None,
235 SevenDayWindow::new(TokenCounts::default()),
236 ));
237 let dc = ctx_with_usage(Ok(data));
238 assert_eq!(
239 ExtraUsageSegment::default().render(&dc, &rc()).unwrap(),
240 None
241 );
242 }
243
244 #[test]
245 fn renders_percent_format_when_configured() {
246 use crate::input::Percent;
247 let extra = ExtraUsage {
248 is_enabled: Some(true),
249 utilization: Some(Percent::new(42.5).unwrap()),
250 monthly_limit: None,
251 used_credits: None,
252 currency: None,
253 };
254 let dc = ctx_with_usage(Ok(data_with_extra(Some(extra))));
255 let seg = ExtraUsageSegment {
256 format: ExtraUsageFormat::Percent,
257 ..Default::default()
258 };
259 let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
260 assert_eq!(rendered.text(), "extra: 42.5%");
261 }
262
263 #[test]
264 fn from_extras_applies_extra_usage_format_knobs() {
265 let mut extras = std::collections::BTreeMap::new();
266 extras.insert("format".into(), toml::Value::String("percent".into()));
267 extras.insert("label".into(), toml::Value::String("overage".into()));
268 let mut warnings = Vec::new();
269 let seg = ExtraUsageSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
270 assert!(warnings.is_empty(), "{warnings:?}");
271 assert_eq!(seg.format, ExtraUsageFormat::Percent);
272 assert_eq!(seg.config.label, "overage");
273 }
274
275 #[test]
276 fn from_extras_warns_on_duration_format_string() {
277 let mut extras = std::collections::BTreeMap::new();
280 extras.insert("format".into(), toml::Value::String("duration".into()));
281 let mut warnings = Vec::new();
282 let _ = ExtraUsageSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
283 assert_eq!(warnings.len(), 1);
284 assert!(warnings[0].contains("format"), "{:?}", warnings[0]);
285 }
286
287 #[test]
288 fn currency_falls_back_to_percent_when_monthly_limit_missing() {
289 use crate::input::Percent;
290 let extra = ExtraUsage {
291 is_enabled: Some(true),
292 utilization: Some(Percent::new(42.5).unwrap()),
293 monthly_limit: None,
294 used_credits: Some(40.0),
295 currency: Some("USD".into()),
296 };
297 let dc = ctx_with_usage(Ok(data_with_extra(Some(extra))));
298 let rendered = ExtraUsageSegment::default()
299 .render(&dc, &rc())
300 .unwrap()
301 .expect("visible");
302 assert_eq!(rendered.text(), "extra: 42.5%");
303 }
304}