Skip to main content

linesmith_core/segments/
extra_usage.rs

1//! `extra_usage` segment: monthly overage credits. Auto-hides when
2//! the account has not enabled overage (`is_enabled = false`); surfaces
3//! error strings for fetch failures per spec §Render semantics.
4
5use 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                // `is_enabled = false` (or missing) hides silently:
56                // the user hasn't opted into overage, so there's no
57                // state worth rendering.
58                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            // Spec §JSONL-fallback display: transcripts carry no
73            // overage data, so the segment hides silently under JSONL.
74            Ok(UsageData::Jsonl(_)) => {
75                crate::lsm_debug!("extra_usage: jsonl fallback has no overage data; hiding");
76                Ok(None)
77            }
78            Err(err) => {
79                // User opted in by enabling this segment; a fetch
80                // failure must surface the error string (not silently
81                // hide) so regressions aren't indistinguishable from
82                // "overage disabled" (spec §Render semantics).
83                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        // Spec §JSONL-fallback display: transcripts carry no overage
230        // data so the segment hides, not errors. ADR-0013 makes this a
231        // type-level guarantee — `UsageData::Jsonl` can't carry
232        // `extra_usage`.
233        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        // `format = "duration"` is valid for reset segments but NOT
278        // for extra_usage; parse_extra_usage_format rejects it.
279        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}