Skip to main content

usage_monitor_cli/provider/
opencode_go.rs

1//! Provider for OpenCode Go via the opencode.ai web dashboard.
2//!
3//! Manual setup: there is no public usage API, so this provider authenticates
4//! with a browser session Cookie header configured by the user and scrapes the
5//! workspace dashboard hydration payload. One cookie can cover multiple
6//! workspaces. See `docs/providers/opencode-go.md` for the full extraction spec.
7
8use async_trait::async_trait;
9use chrono::Utc;
10
11use crate::error::SpendPanelError;
12use crate::model::{NamedRateWindow, RateWindow, RateWindowStatus, UsageSnapshot};
13use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
14
15const DEFAULT_BASE: &str = "https://opencode.ai";
16/// Build-specific hash of the SolidStart server function that lists
17/// workspaces. Changes when opencode.ai redeploys; users can bypass discovery
18/// by configuring `workspaces` explicitly.
19const WORKSPACES_SERVER_ID: &str =
20    "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f";
21const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
22
23/// One parsed usage window from the dashboard payload.
24#[derive(Debug, Clone, Copy, PartialEq)]
25struct ParsedWindow {
26    /// 0–100.
27    percent: f64,
28    reset_in_sec: i64,
29}
30
31/// A workspace reference: id plus an optional human-readable name.
32///
33/// Names come from the discovery payload (fetched automatically) or from a
34/// manual `wrk_id=Name` config entry, which takes precedence.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct WorkspaceRef {
37    pub id: String,
38    pub name: Option<String>,
39}
40
41impl WorkspaceRef {
42    /// Name when known, id otherwise.
43    pub fn display_name(&self) -> &str {
44        self.name.as_deref().unwrap_or(&self.id)
45    }
46
47    /// Serializes back to a config entry (`wrk_id` or `wrk_id=Name`).
48    pub fn to_entry(&self) -> String {
49        match &self.name {
50            Some(name) => format!("{}={}", self.id, name),
51            None => self.id.clone(),
52        }
53    }
54}
55
56/// Usage of a single workspace.
57#[derive(Debug, Clone, PartialEq)]
58struct WorkspaceUsage {
59    workspace: WorkspaceRef,
60    rolling: ParsedWindow,
61    weekly: ParsedWindow,
62    monthly: Option<ParsedWindow>,
63}
64
65pub struct OpenCodeGoProvider {
66    metadata: ProviderMetadata,
67    /// Base URL override for tests.
68    base_url: Option<String>,
69}
70
71impl OpenCodeGoProvider {
72    pub fn new() -> Self {
73        Self {
74            metadata: ProviderMetadata {
75                id: "opencode-go",
76                name: "OpenCode Go",
77                description: "OpenCode Go workspace usage via opencode.ai dashboard (manual cookie)",
78                auth_methods: &["cookie"],
79                website: Some("https://opencode.ai"),
80            },
81            base_url: None,
82        }
83    }
84
85    /// Creates a provider with a custom base URL (for tests).
86    pub fn with_base_url(url: &str) -> Self {
87        let mut p = Self::new();
88        p.base_url = Some(url.to_string());
89        p
90    }
91
92    fn api_base(&self) -> &str {
93        self.base_url.as_deref().unwrap_or(DEFAULT_BASE)
94    }
95
96    fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
97        reqwest::Client::builder()
98            .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
99            .redirect(reqwest::redirect::Policy::none())
100            .build()
101            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
102    }
103
104    /// Session cookie from the `token` config field (`cookie` kept as alias).
105    fn resolve_cookie(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
106        let raw = ctx.config.get("token").or_else(|| ctx.config.get("cookie"));
107        match raw.map(|c| c.trim()) {
108            Some(cookie) if !cookie.is_empty() => Ok(normalize_cookie_header(cookie)),
109            _ => Err(SpendPanelError::AuthFailed(
110                "opencode-go".into(),
111                "no session token configured; run `usage-monitor opencode-go set token \"<Cookie header or auth value>\"` (see docs/providers/opencode-go.md)".into(),
112            )),
113        }
114    }
115
116    /// Workspace refs from config (`workspaces = "wrk_a=Name,wrk_b"`), if set.
117    fn configured_workspaces(ctx: &ProviderContext) -> Option<Vec<WorkspaceRef>> {
118        let raw = ctx.config.get("workspaces")?;
119        let refs: Vec<WorkspaceRef> = raw.split(',').filter_map(parse_workspace_entry).collect();
120        if refs.is_empty() { None } else { Some(refs) }
121    }
122
123    /// A 200 response can still be a login page; detect signed-out payloads.
124    fn looks_signed_out(text: &str) -> bool {
125        let lower = text.to_lowercase();
126        lower.contains("login")
127            || lower.contains("sign in")
128            || lower.contains("auth/authorize")
129            || lower.contains("not associated with an account")
130            || lower.contains("actor of type \"public\"")
131    }
132
133    /// Discovers workspaces (id + name) via the internal server function.
134    async fn discover_workspaces(
135        base: &str,
136        client: &reqwest::Client,
137        cookie: &str,
138    ) -> Result<Vec<WorkspaceRef>, SpendPanelError> {
139        let url = format!("{}/_server?id={}", base, WORKSPACES_SERVER_ID);
140        let resp = client
141            .get(&url)
142            .header("cookie", cookie)
143            .header("x-server-id", WORKSPACES_SERVER_ID)
144            .header(
145                "x-server-instance",
146                format!("server-fn:{:x}", std::process::id()),
147            )
148            .header("origin", base.to_string())
149            .header("referer", format!("{}/", base))
150            .header(
151                "accept",
152                "text/javascript, application/json;q=0.9, */*;q=0.8",
153            )
154            .header("user-agent", USER_AGENT)
155            .send()
156            .await
157            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
158
159        let status = resp.status();
160        let body = resp
161            .text()
162            .await
163            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
164
165        if status == 401 || status == 403 || Self::looks_signed_out(&body) {
166            return Err(SpendPanelError::AuthFailed(
167                "opencode-go".into(),
168                "session cookie rejected or expired; copy a fresh Cookie header".into(),
169            ));
170        }
171        if !status.is_success() {
172            return Err(SpendPanelError::ProviderError(
173                "opencode-go".into(),
174                format!("workspace discovery HTTP {}", status),
175            ));
176        }
177
178        let refs = parse_discovered_workspaces(&body);
179        if refs.is_empty() {
180            return Err(SpendPanelError::ParseError(
181                "opencode-go".into(),
182                "no workspace ids in discovery payload; configure `workspaces` manually".into(),
183            ));
184        }
185        Ok(refs)
186    }
187
188    /// Fetches and parses the usage of one workspace dashboard page.
189    async fn fetch_workspace_usage(
190        base: &str,
191        client: &reqwest::Client,
192        cookie: &str,
193        workspace: &WorkspaceRef,
194    ) -> Result<WorkspaceUsage, SpendPanelError> {
195        let workspace_id = &workspace.id;
196        let url = format!("{}/workspace/{}/go", base, workspace_id);
197        let resp = client
198            .get(&url)
199            .header("cookie", cookie)
200            .header("user-agent", USER_AGENT)
201            .header(
202                "accept",
203                "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
204            )
205            .send()
206            .await
207            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
208
209        let status = resp.status();
210        let body = resp
211            .text()
212            .await
213            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
214
215        if status == 401 || status == 403 || Self::looks_signed_out(&body) {
216            return Err(SpendPanelError::AuthFailed(
217                "opencode-go".into(),
218                "session cookie rejected or expired; copy a fresh Cookie header".into(),
219            ));
220        }
221        if !status.is_success() {
222            return Err(SpendPanelError::ProviderError(
223                "opencode-go".into(),
224                format!("workspace {} HTTP {}", workspace_id, status),
225            ));
226        }
227
228        parse_workspace_page(workspace, &body)
229    }
230
231    fn rate_window(label: String, window_minutes: u32, w: &ParsedWindow) -> RateWindow {
232        let ratio = (w.percent / 100.0).clamp(0.0, 1.0);
233        RateWindow {
234            label,
235            window_minutes,
236            usage_ratio: ratio,
237            limit: None,
238            used: None,
239            remaining: None,
240            resets_at: Some(Utc::now() + chrono::Duration::seconds(w.reset_in_sec.max(0))),
241            status: RateWindowStatus::from_ratio(ratio),
242        }
243    }
244
245    fn snapshot_from_usages(usages: &[WorkspaceUsage]) -> UsageSnapshot {
246        let mut snapshot = UsageSnapshot::new("opencode-go");
247        snapshot.collected_at = Utc::now();
248
249        let Some(first) = usages.first() else {
250            return snapshot;
251        };
252
253        let first_name = first.workspace.display_name();
254
255        snapshot.primary_rate_window = Some(Self::rate_window(
256            format!("{} Rolling (5h)", first_name),
257            300,
258            &first.rolling,
259        ));
260        snapshot.secondary_rate_window = Some(Self::rate_window(
261            format!("{} Weekly", first_name),
262            10_080,
263            &first.weekly,
264        ));
265        if let Some(monthly) = &first.monthly {
266            snapshot.tertiary_rate_window = Some(Self::rate_window(
267                format!("{} Monthly", first_name),
268                43_200,
269                monthly,
270            ));
271        }
272
273        // Additional workspaces (same cookie) become named extra windows.
274        for usage in &usages[1..] {
275            let ws = &usage.workspace;
276            let name = ws.display_name();
277            snapshot.extra_rate_windows.push(NamedRateWindow {
278                id: format!("{}-rolling", ws.id),
279                label: format!("{} Rolling (5h)", name),
280                window: Self::rate_window(format!("{} Rolling (5h)", name), 300, &usage.rolling),
281            });
282            snapshot.extra_rate_windows.push(NamedRateWindow {
283                id: format!("{}-weekly", ws.id),
284                label: format!("{} Weekly", name),
285                window: Self::rate_window(format!("{} Weekly", name), 10_080, &usage.weekly),
286            });
287            if let Some(monthly) = &usage.monthly {
288                snapshot.extra_rate_windows.push(NamedRateWindow {
289                    id: format!("{}-monthly", ws.id),
290                    label: format!("{} Monthly", name),
291                    window: Self::rate_window(format!("{} Monthly", name), 43_200, monthly),
292                });
293            }
294        }
295
296        snapshot
297    }
298}
299
300/// Normalizes user-provided auth into a valid Cookie header value.
301///
302/// Accepted inputs:
303/// - Full Cookie header value: `auth=Fe26...; other=value`
304/// - Header line copied with name: `Cookie: auth=Fe26...`
305/// - Bare opencode auth cookie value: `Fe26...` → `auth=Fe26...`
306fn normalize_cookie_header(raw: &str) -> String {
307    let value = raw.trim();
308    let value = value
309        .strip_prefix("Cookie:")
310        .or_else(|| value.strip_prefix("cookie:"))
311        .map(str::trim)
312        .unwrap_or(value);
313
314    if looks_like_cookie_header(value) {
315        value.to_string()
316    } else {
317        format!("auth={}", value)
318    }
319}
320
321fn looks_like_cookie_header(value: &str) -> bool {
322    let first_pair = value.split(';').next().unwrap_or(value).trim();
323    let Some((name, cookie_value)) = first_pair.split_once('=') else {
324        return false;
325    };
326    !name.trim().is_empty()
327        && !cookie_value.trim().is_empty()
328        && name
329            .chars()
330            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'))
331}
332
333/// Normalizes a workspace reference: bare `wrk_...` id, a dashboard URL
334/// containing `/workspace/<id>/`, or any string embedding a `wrk_` id.
335pub fn normalize_workspace_id(raw: &str) -> Option<String> {
336    let trimmed = raw.trim();
337    if trimmed.is_empty() {
338        return None;
339    }
340    let start = trimmed.find("wrk_")?;
341    let id: String = trimmed[start..]
342        .chars()
343        .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
344        .collect();
345    if id.len() > 4 { Some(id) } else { None }
346}
347
348/// Parses a config entry: `wrk_id`, `wrk_id=Name`, or a dashboard URL
349/// (optionally with `=Name`).
350pub fn parse_workspace_entry(raw: &str) -> Option<WorkspaceRef> {
351    let (id_part, name) = match raw.split_once('=') {
352        Some((id, name)) if !name.trim().is_empty() => (id, Some(name.trim().to_string())),
353        Some((id, _)) => (id, None),
354        None => (raw, None),
355    };
356    let id = normalize_workspace_id(id_part)?;
357    Some(WorkspaceRef { id, name })
358}
359
360fn validate_workspace_name(name: &str) -> Result<(), SpendPanelError> {
361    if name.contains(',') {
362        return Err(SpendPanelError::ConfigError(
363            "workspace name cannot contain comma".into(),
364        ));
365    }
366    Ok(())
367}
368
369fn canonical_workspace_refs(list: &[String]) -> Vec<WorkspaceRef> {
370    let mut refs: Vec<WorkspaceRef> = Vec::new();
371    for ws in list.iter().filter_map(|e| parse_workspace_entry(e)) {
372        match refs.iter_mut().find(|existing| existing.id == ws.id) {
373            Some(existing) => {
374                if ws.name.is_some() {
375                    existing.name = ws.name;
376                }
377            }
378            None => refs.push(ws),
379        }
380    }
381    refs
382}
383
384/// Adds a workspace (id or dashboard URL, with an optional name) to a list of
385/// config entries. Errors when the reference has no `wrk_` id. Adding an
386/// existing id only succeeds when a new/changed name is provided; unchanged
387/// duplicates are rejected.
388pub fn add_workspace(
389    list: &[String],
390    raw: &str,
391    name: Option<&str>,
392) -> Result<Vec<String>, SpendPanelError> {
393    let mut new_ref = parse_workspace_entry(raw).ok_or_else(|| {
394        SpendPanelError::ConfigError(format!(
395            "'{}' has no workspace id; expected wrk_... or a dashboard URL like https://opencode.ai/workspace/wrk_xxx/go",
396            raw
397        ))
398    })?;
399    if let Some(name) = name.map(str::trim).filter(|n| !n.is_empty()) {
400        validate_workspace_name(name)?;
401        new_ref.name = Some(name.to_string());
402    }
403    if let Some(name) = &new_ref.name {
404        validate_workspace_name(name)?;
405    }
406
407    let mut refs = canonical_workspace_refs(list);
408    match refs.iter_mut().find(|r| r.id == new_ref.id) {
409        Some(existing) => {
410            if new_ref.name.is_some() && existing.name != new_ref.name {
411                existing.name = new_ref.name;
412            } else {
413                return Err(SpendPanelError::ConfigError(format!(
414                    "workspace '{}' is already configured",
415                    existing.id
416                )));
417            }
418        }
419        None => refs.push(new_ref),
420    }
421    Ok(refs.iter().map(WorkspaceRef::to_entry).collect())
422}
423
424/// Removes a workspace (matched by id) from a list of config entries.
425pub fn remove_workspace(list: &[String], raw: &str) -> Result<Vec<String>, SpendPanelError> {
426    let id = normalize_workspace_id(raw).ok_or_else(|| {
427        SpendPanelError::ConfigError(format!("'{}' has no workspace id (expected wrk_...)", raw))
428    })?;
429    let refs = canonical_workspace_refs(list);
430    if !refs.iter().any(|r| r.id == id) {
431        return Err(SpendPanelError::ConfigError(format!(
432            "workspace '{}' is not configured",
433            id
434        )));
435    }
436
437    Ok(refs
438        .into_iter()
439        .filter(|r| r.id != id)
440        .map(|r| r.to_entry())
441        .collect())
442}
443
444/// Extracts workspaces (`wrk_...` id plus the `name:"..."` that follows it in
445/// the same object, when present) from a discovery payload.
446fn parse_discovered_workspaces(text: &str) -> Vec<WorkspaceRef> {
447    let mut refs: Vec<WorkspaceRef> = Vec::new();
448    let mut rest = text;
449    while let Some(pos) = rest.find("wrk_") {
450        let candidate: String = rest[pos..]
451            .chars()
452            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
453            .collect();
454        let after = &rest[pos + candidate.len().max(4)..];
455        if candidate.len() > 4 && !refs.iter().any(|r| r.id == candidate) {
456            // The name sits in the same object, e.g. {id:"wrk_x",name:"Default"}.
457            let segment_end = after.find('}').unwrap_or(after.len());
458            let name = extract_string(&after[..segment_end], "name");
459            refs.push(WorkspaceRef {
460                id: candidate,
461                name,
462            });
463        }
464        rest = after;
465    }
466    refs
467}
468
469/// Finds `key:"value"` (or `"key":"value"`) inside a segment.
470fn extract_string(segment: &str, key: &str) -> Option<String> {
471    let pos = segment.find(key)?;
472    let after = segment[pos + key.len()..]
473        .trim_start_matches('"')
474        .trim_start();
475    let after = after.strip_prefix(':')?.trim_start();
476    let after = after.strip_prefix('"')?;
477    let end = after.find('"')?;
478    let value = &after[..end];
479    if value.is_empty() {
480        None
481    } else {
482        Some(value.to_string())
483    }
484}
485
486/// Extracts `<window>...usagePercent: N` / `resetInSec: N` pairs from the
487/// dashboard hydration payload.
488fn parse_window(text: &str, window_key: &str) -> Option<ParsedWindow> {
489    for (start, _) in text.match_indices(window_key) {
490        // Window object ends at the first closing brace after the key. Some
491        // payloads also contain scalar billing fields such as
492        // `monthlyUsage:null` before the real workspace usage object; skip
493        // segments without `usagePercent` and keep searching.
494        let segment_end = text[start..]
495            .find('}')
496            .map(|i| start + i)
497            .unwrap_or(text.len());
498        let segment = &text[start..segment_end];
499
500        let Some(percent) = extract_number(segment, "usagePercent") else {
501            continue;
502        };
503        let reset_in_sec = extract_number(segment, "resetInSec").unwrap_or(0.0) as i64;
504
505        // `usagePercent` is already a percentage (0–100) and may carry decimal
506        // places (e.g. 0.7 means 0.7%). Older payload variants emitted ratios,
507        // but values ≤ 1.0 must not be scaled: that would turn 0.7% into 70%.
508        return Some(ParsedWindow {
509            percent: percent.clamp(0.0, 100.0),
510            reset_in_sec,
511        });
512    }
513
514    None
515}
516
517/// Finds `key: <number>` (with optional quotes around the number) inside a segment.
518fn extract_number(segment: &str, key: &str) -> Option<f64> {
519    let pos = segment.find(key)?;
520    let after = &segment[pos + key.len()..];
521    let after = after.trim_start().strip_prefix(':')?.trim_start();
522    let after = after.strip_prefix('"').unwrap_or(after);
523    let number: String = after
524        .chars()
525        .take_while(|c| c.is_ascii_digit() || *c == '.')
526        .collect();
527    number.parse().ok()
528}
529
530fn parse_workspace_page(
531    workspace: &WorkspaceRef,
532    text: &str,
533) -> Result<WorkspaceUsage, SpendPanelError> {
534    let rolling = parse_window(text, "rollingUsage");
535    let weekly = parse_window(text, "weeklyUsage");
536    match (rolling, weekly) {
537        (Some(rolling), Some(weekly)) => Ok(WorkspaceUsage {
538            workspace: workspace.clone(),
539            rolling,
540            weekly,
541            monthly: parse_window(text, "monthlyUsage"),
542        }),
543        _ => Err(SpendPanelError::ParseError(
544            "opencode-go".into(),
545            format!("workspace {} page is missing usage fields", workspace.id),
546        )),
547    }
548}
549
550impl Default for OpenCodeGoProvider {
551    fn default() -> Self {
552        Self::new()
553    }
554}
555
556#[async_trait]
557impl UsageProvider for OpenCodeGoProvider {
558    fn metadata(&self) -> &ProviderMetadata {
559        &self.metadata
560    }
561
562    // Manual provider: nothing detectable on disk, stays disabled until the
563    // user configures a cookie and enables it.
564    fn detect_credentials(&self) -> bool {
565        false
566    }
567
568    async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
569        let cookie = Self::resolve_cookie(ctx)?;
570        let client = Self::build_client(ctx)?;
571        let base = self.api_base();
572
573        let workspaces = match Self::configured_workspaces(ctx) {
574            Some(mut refs) => {
575                // Pinned ids may lack names; enrich them from discovery on a
576                // best-effort basis (manual names always win).
577                if refs.iter().any(|r| r.name.is_none())
578                    && let Ok(discovered) = Self::discover_workspaces(base, &client, &cookie).await
579                {
580                    for r in refs.iter_mut().filter(|r| r.name.is_none()) {
581                        r.name = discovered
582                            .iter()
583                            .find(|d| d.id == r.id)
584                            .and_then(|d| d.name.clone());
585                    }
586                }
587                refs
588            }
589            None => Self::discover_workspaces(base, &client, &cookie).await?,
590        };
591
592        let mut usages = Vec::new();
593        let mut first_error: Option<SpendPanelError> = None;
594        for ws in &workspaces {
595            match Self::fetch_workspace_usage(base, &client, &cookie, ws).await {
596                Ok(usage) => usages.push(usage),
597                Err(e) => {
598                    tracing::warn!("opencode-go workspace {} failed: {}", ws.id, e);
599                    if first_error.is_none() {
600                        first_error = Some(e);
601                    }
602                }
603            }
604        }
605
606        if usages.is_empty() {
607            return Err(first_error.unwrap_or_else(|| {
608                SpendPanelError::ProviderError("opencode-go".into(), "no workspaces fetched".into())
609            }));
610        }
611
612        Ok(Self::snapshot_from_usages(&usages))
613    }
614}
615
616// ---------------------------------------------------------------------------
617// Tests
618// ---------------------------------------------------------------------------
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use wiremock::matchers::{header, method, path};
624    use wiremock::{Mock, MockServer, ResponseTemplate};
625
626    fn dashboard_page(rolling_pct: f64, weekly_pct: f64, monthly: Option<f64>) -> String {
627        let monthly_part = monthly
628            .map(|m| format!(r#"monthlyUsage:{{usagePercent:{},resetInSec:864000}},"#, m))
629            .unwrap_or_default();
630        format!(
631            r#"<html><body><script>self.__data={{billing:{{rollingUsage:{{usagePercent:{},resetInSec:3600}},weeklyUsage:{{usagePercent:{},resetInSec:172800}},{}plan:"go"}}}};</script></body></html>"#,
632            rolling_pct, weekly_pct, monthly_part
633        )
634    }
635
636    #[test]
637    fn test_normalize_workspace_id() {
638        assert_eq!(
639            normalize_workspace_id("wrk_abc123"),
640            Some("wrk_abc123".into())
641        );
642        assert_eq!(
643            normalize_workspace_id("  wrk_abc123  "),
644            Some("wrk_abc123".into())
645        );
646        assert_eq!(
647            normalize_workspace_id("https://opencode.ai/workspace/wrk_abc123/go"),
648            Some("wrk_abc123".into())
649        );
650        assert_eq!(normalize_workspace_id("wrk_"), None);
651        assert_eq!(normalize_workspace_id("nope"), None);
652        assert_eq!(normalize_workspace_id(""), None);
653    }
654
655    #[test]
656    fn test_add_workspace() {
657        let v = add_workspace(&[], "wrk_a", None).unwrap();
658        assert_eq!(v, vec!["wrk_a"]);
659        let v = add_workspace(&v, "https://opencode.ai/workspace/wrk_b/go", None).unwrap();
660        assert_eq!(v, vec!["wrk_a", "wrk_b"]);
661        // Duplicate without changes is rejected.
662        let err = add_workspace(&v, "wrk_a", None).unwrap_err();
663        assert!(err.to_string().contains("already configured"));
664        assert!(add_workspace(&v, "not-a-workspace", None).is_err());
665    }
666
667    #[test]
668    fn test_add_workspace_with_name() {
669        let v = add_workspace(&[], "wrk_a", Some("Production")).unwrap();
670        assert_eq!(v, vec!["wrk_a=Production"]);
671        // Re-adding with a new name updates it.
672        let v = add_workspace(&v, "wrk_a", Some("Staging")).unwrap();
673        assert_eq!(v, vec!["wrk_a=Staging"]);
674        // Re-adding without a name is rejected because it would not change anything.
675        let err = add_workspace(&v, "wrk_a", None).unwrap_err();
676        assert!(err.to_string().contains("already configured"));
677        // Re-adding with the same name is also rejected.
678        let err = add_workspace(&v, "wrk_a", Some("Staging")).unwrap_err();
679        assert!(err.to_string().contains("already configured"));
680    }
681
682    #[test]
683    fn test_add_workspace_rejects_comma_in_name() {
684        let err = add_workspace(&[], "wrk_a", Some("Client, Production")).unwrap_err();
685        assert!(err.to_string().contains("cannot contain comma"));
686
687        let err = add_workspace(&[], "wrk_a=Client, Production", None).unwrap_err();
688        assert!(err.to_string().contains("cannot contain comma"));
689    }
690
691    #[test]
692    fn test_add_workspace_deduplicates_existing_config() {
693        let list = vec![
694            "wrk_a".to_string(),
695            "wrk_a=Production".to_string(),
696            "wrk_b=Old".to_string(),
697            "wrk_b=New".to_string(),
698        ];
699
700        let v = add_workspace(&list, "wrk_c", None).unwrap();
701        assert_eq!(v, vec!["wrk_a=Production", "wrk_b=New", "wrk_c"]);
702    }
703
704    #[test]
705    fn test_parse_workspace_entry() {
706        assert_eq!(
707            parse_workspace_entry("wrk_a=Prod"),
708            Some(WorkspaceRef {
709                id: "wrk_a".into(),
710                name: Some("Prod".into())
711            })
712        );
713        assert_eq!(
714            parse_workspace_entry("wrk_a"),
715            Some(WorkspaceRef {
716                id: "wrk_a".into(),
717                name: None
718            })
719        );
720        assert_eq!(
721            parse_workspace_entry("https://opencode.ai/workspace/wrk_a/go=My Team"),
722            Some(WorkspaceRef {
723                id: "wrk_a".into(),
724                name: Some("My Team".into())
725            })
726        );
727        assert_eq!(parse_workspace_entry("garbage"), None);
728    }
729
730    #[test]
731    fn test_remove_workspace() {
732        let list = vec!["wrk_a".to_string(), "wrk_b".to_string()];
733        assert_eq!(remove_workspace(&list, "wrk_a").unwrap(), vec!["wrk_b"]);
734        assert!(remove_workspace(&list[..1], "wrk_a").unwrap().is_empty());
735        let err = remove_workspace(&list, "wrk_other").unwrap_err();
736        assert!(err.to_string().contains("not configured"));
737        // Invalid reference is an error, not a silent wipe.
738        assert!(remove_workspace(&list, "garbage").is_err());
739    }
740
741    #[test]
742    fn test_remove_workspace_deduplicates_remaining_config() {
743        let list = vec![
744            "wrk_a".to_string(),
745            "wrk_b".to_string(),
746            "wrk_b=Production".to_string(),
747            "wrk_c".to_string(),
748            "wrk_c".to_string(),
749        ];
750
751        let v = remove_workspace(&list, "wrk_a").unwrap();
752        assert_eq!(v, vec!["wrk_b=Production", "wrk_c"]);
753    }
754
755    #[test]
756    fn test_parse_discovered_workspaces() {
757        let payload = r#"{"workspaces":[{"id":"wrk_aaa1","name":"Production"},{"id":"wrk_bbb2"},{"id":"wrk_aaa1"}]}"#;
758        let refs = parse_discovered_workspaces(payload);
759        assert_eq!(refs.len(), 2);
760        assert_eq!(refs[0].id, "wrk_aaa1");
761        assert_eq!(refs[0].name.as_deref(), Some("Production"));
762        assert_eq!(refs[1].id, "wrk_bbb2");
763        assert_eq!(refs[1].name, None);
764        assert!(parse_discovered_workspaces("no ids here").is_empty());
765    }
766
767    #[test]
768    fn test_parse_discovered_workspaces_hydration_payload() {
769        // Unquoted-key hydration format used by the dashboard's JS payload.
770        let payload =
771            r#"($R=>$R[0]=[$R[1]={id:"wrk_01K6AR1ZET89H8NB691FQ2C2VB",name:"Default",slug:null}])"#;
772        let refs = parse_discovered_workspaces(payload);
773        assert_eq!(refs.len(), 1);
774        assert_eq!(refs[0].id, "wrk_01K6AR1ZET89H8NB691FQ2C2VB");
775        assert_eq!(refs[0].name.as_deref(), Some("Default"));
776    }
777
778    #[test]
779    fn test_parse_window_percent_and_reset() {
780        let page = dashboard_page(42.5, 80.0, Some(12.0));
781        let rolling = parse_window(&page, "rollingUsage").unwrap();
782        assert_eq!(rolling.percent, 42.5);
783        assert_eq!(rolling.reset_in_sec, 3600);
784
785        let weekly = parse_window(&page, "weeklyUsage").unwrap();
786        assert_eq!(weekly.percent, 80.0);
787
788        let monthly = parse_window(&page, "monthlyUsage").unwrap();
789        assert_eq!(monthly.percent, 12.0);
790    }
791
792    #[test]
793    fn test_parse_window_skips_scalar_billing_usage() {
794        let page = r#"
795            billing:{monthlyUsage:null,timeMonthlyUsageUpdated:null}
796            workspace:{monthlyUsage:{status:"ok",resetInSec:72652,usagePercent:99}}
797        "#;
798
799        let monthly = parse_window(page, "monthlyUsage").unwrap();
800        assert_eq!(monthly.percent, 99.0);
801        assert_eq!(monthly.reset_in_sec, 72652);
802    }
803
804    #[test]
805    fn test_parse_window_fractional_percent_not_scaled() {
806        // The dashboard reports percentages directly, with decimal places:
807        // 0.7 means 0.7%, not 70% — it must not be scaled by 100.
808        let page = "rollingUsage:{usagePercent:0.7,resetInSec:60}";
809        let w = parse_window(page, "rollingUsage").unwrap();
810        assert!((w.percent - 0.7).abs() < 1e-9);
811
812        let page = "rollingUsage:{usagePercent:0.42,resetInSec:60}";
813        let w = parse_window(page, "rollingUsage").unwrap();
814        assert!((w.percent - 0.42).abs() < 1e-9);
815    }
816
817    #[test]
818    fn test_parse_window_clamps_over_100() {
819        let page = "rollingUsage:{usagePercent:140,resetInSec:60}";
820        let w = parse_window(page, "rollingUsage").unwrap();
821        assert_eq!(w.percent, 100.0);
822    }
823
824    #[test]
825    fn test_parse_workspace_page_missing_fields() {
826        let ws = WorkspaceRef {
827            id: "wrk_x".into(),
828            name: None,
829        };
830        let result = parse_workspace_page(&ws, "<html>nothing here</html>");
831        assert!(matches!(result, Err(SpendPanelError::ParseError(_, _))));
832    }
833
834    #[test]
835    fn test_looks_signed_out() {
836        assert!(OpenCodeGoProvider::looks_signed_out(
837            "<a href=\"/auth/authorize\">Sign in</a>"
838        ));
839        assert!(OpenCodeGoProvider::looks_signed_out(
840            r#"actor of type "public""#
841        ));
842        assert!(!OpenCodeGoProvider::looks_signed_out(
843            "rollingUsage:{usagePercent:1}"
844        ));
845    }
846
847    #[test]
848    fn test_resolve_cookie_missing() {
849        let ctx = ProviderContext::new();
850        assert!(matches!(
851            OpenCodeGoProvider::resolve_cookie(&ctx),
852            Err(SpendPanelError::AuthFailed(_, _))
853        ));
854    }
855
856    #[test]
857    fn test_resolve_cookie_token_field_preferred() {
858        let mut ctx = ProviderContext::new();
859        ctx.config.insert("token".into(), "session=token".into());
860        ctx.config.insert("cookie".into(), "session=alias".into());
861        assert_eq!(
862            OpenCodeGoProvider::resolve_cookie(&ctx).unwrap(),
863            "session=token"
864        );
865
866        let mut ctx = ProviderContext::new();
867        ctx.config.insert("cookie".into(), "session=alias".into());
868        assert_eq!(
869            OpenCodeGoProvider::resolve_cookie(&ctx).unwrap(),
870            "session=alias"
871        );
872    }
873
874    #[test]
875    fn test_normalize_cookie_header_accepts_full_cookie_header() {
876        assert_eq!(
877            normalize_cookie_header("auth=Fe26.2**abc; other=value"),
878            "auth=Fe26.2**abc; other=value"
879        );
880    }
881
882    #[test]
883    fn test_normalize_cookie_header_strips_cookie_prefix() {
884        assert_eq!(
885            normalize_cookie_header("Cookie: auth=Fe26.2**abc; other=value"),
886            "auth=Fe26.2**abc; other=value"
887        );
888        assert_eq!(
889            normalize_cookie_header("cookie: auth=Fe26.2**abc"),
890            "auth=Fe26.2**abc"
891        );
892    }
893
894    #[test]
895    fn test_normalize_cookie_header_prefixes_bare_auth_value() {
896        assert_eq!(normalize_cookie_header("Fe26.2**abc"), "auth=Fe26.2**abc");
897    }
898
899    #[test]
900    fn test_provider_metadata() {
901        let p = OpenCodeGoProvider::new();
902        assert_eq!(p.metadata().id, "opencode-go");
903        assert!(!p.detect_credentials());
904    }
905
906    #[tokio::test]
907    async fn test_fetch_with_configured_workspaces() {
908        let server = MockServer::start().await;
909
910        Mock::given(method("GET"))
911            .and(path("/workspace/wrk_one/go"))
912            .and(header("cookie", "session=abc"))
913            .respond_with(
914                ResponseTemplate::new(200).set_body_string(dashboard_page(10.0, 50.0, None)),
915            )
916            .mount(&server)
917            .await;
918        Mock::given(method("GET"))
919            .and(path("/workspace/wrk_two/go"))
920            .respond_with(ResponseTemplate::new(200).set_body_string(dashboard_page(
921                95.0,
922                99.0,
923                Some(40.0),
924            )))
925            .mount(&server)
926            .await;
927
928        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
929        let mut ctx = ProviderContext::new();
930        ctx.config.insert("cookie".into(), "session=abc".into());
931        ctx.config
932            .insert("workspaces".into(), "wrk_one, wrk_two".into());
933
934        let snap = provider.fetch_usage(&ctx).await.unwrap();
935        assert_eq!(snap.provider_id, "opencode-go");
936
937        let primary = snap.primary_rate_window.unwrap();
938        assert!((primary.usage_ratio - 0.10).abs() < 1e-9);
939        assert_eq!(primary.window_minutes, 300);
940        assert!(snap.tertiary_rate_window.is_none()); // first ws has no monthly
941
942        // Second workspace → extra windows (rolling, weekly, monthly).
943        assert_eq!(snap.extra_rate_windows.len(), 3);
944        assert_eq!(snap.extra_rate_windows[0].id, "wrk_two-rolling");
945        assert_eq!(snap.extra_rate_windows[2].id, "wrk_two-monthly");
946        assert_eq!(snap.extra_rate_windows[2].label, "wrk_two Monthly");
947        assert_eq!(
948            snap.extra_rate_windows[1].window.status,
949            RateWindowStatus::Critical
950        );
951    }
952
953    #[tokio::test]
954    async fn test_fetch_discovers_workspaces() {
955        let server = MockServer::start().await;
956
957        Mock::given(method("GET"))
958            .and(path("/_server"))
959            .and(header("x-server-id", WORKSPACES_SERVER_ID))
960            .respond_with(
961                ResponseTemplate::new(200).set_body_string(r#"[{"id":"wrk_disc","slug":"main"}]"#),
962            )
963            .mount(&server)
964            .await;
965        Mock::given(method("GET"))
966            .and(path("/workspace/wrk_disc/go"))
967            .respond_with(ResponseTemplate::new(200).set_body_string(dashboard_page(
968                30.0,
969                60.0,
970                Some(5.0),
971            )))
972            .mount(&server)
973            .await;
974
975        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
976        let mut ctx = ProviderContext::new();
977        ctx.config.insert("cookie".into(), "session=abc".into());
978
979        let snap = provider.fetch_usage(&ctx).await.unwrap();
980        assert!((snap.primary_rate_window.unwrap().usage_ratio - 0.30).abs() < 1e-9);
981        assert!(snap.tertiary_rate_window.is_some());
982        assert!(snap.extra_rate_windows.is_empty());
983    }
984
985    #[tokio::test]
986    async fn test_discovered_names_label_windows() {
987        let server = MockServer::start().await;
988
989        Mock::given(method("GET"))
990            .and(path("/_server"))
991            .respond_with(ResponseTemplate::new(200).set_body_string(
992                r#"[{"id":"wrk_one","name":"Production"},{"id":"wrk_two","name":"Staging"}]"#,
993            ))
994            .mount(&server)
995            .await;
996        Mock::given(method("GET"))
997            .and(path("/workspace/wrk_one/go"))
998            .respond_with(
999                ResponseTemplate::new(200).set_body_string(dashboard_page(10.0, 50.0, None)),
1000            )
1001            .mount(&server)
1002            .await;
1003        Mock::given(method("GET"))
1004            .and(path("/workspace/wrk_two/go"))
1005            .respond_with(
1006                ResponseTemplate::new(200).set_body_string(dashboard_page(20.0, 60.0, None)),
1007            )
1008            .mount(&server)
1009            .await;
1010
1011        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
1012        let mut ctx = ProviderContext::new();
1013        ctx.config.insert("cookie".into(), "session=abc".into());
1014
1015        let snap = provider.fetch_usage(&ctx).await.unwrap();
1016        assert_eq!(
1017            snap.primary_rate_window.unwrap().label,
1018            "Production Rolling (5h)"
1019        );
1020        assert_eq!(snap.extra_rate_windows[0].label, "Staging Rolling (5h)");
1021    }
1022
1023    #[tokio::test]
1024    async fn test_fetch_fractional_percent_not_scaled() {
1025        let server = MockServer::start().await;
1026
1027        Mock::given(method("GET"))
1028            .and(path("/workspace/wrk_x/go"))
1029            .respond_with(ResponseTemplate::new(200).set_body_string(dashboard_page(
1030                0.7,
1031                12.3,
1032                Some(0.5),
1033            )))
1034            .mount(&server)
1035            .await;
1036
1037        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
1038        let mut ctx = ProviderContext::new();
1039        ctx.config.insert("cookie".into(), "session=abc".into());
1040        ctx.config.insert("workspaces".into(), "wrk_x".into());
1041
1042        let snap = provider.fetch_usage(&ctx).await.unwrap();
1043        let primary = snap.primary_rate_window.unwrap();
1044        // 0.7% must map to 0.007, not 0.7 (70%).
1045        assert!((primary.usage_ratio - 0.007).abs() < 1e-9);
1046        let secondary = snap.secondary_rate_window.unwrap();
1047        assert!((secondary.usage_ratio - 0.123).abs() < 1e-9);
1048        let tertiary = snap.tertiary_rate_window.unwrap();
1049        assert!((tertiary.usage_ratio - 0.005).abs() < 1e-9);
1050    }
1051
1052    #[tokio::test]
1053    async fn test_pinned_ids_enriched_with_discovered_names() {
1054        let server = MockServer::start().await;
1055
1056        Mock::given(method("GET"))
1057            .and(path("/_server"))
1058            .respond_with(ResponseTemplate::new(200).set_body_string(
1059                r#"[{"id":"wrk_one","name":"Production"},{"id":"wrk_two","name":"Staging"}]"#,
1060            ))
1061            .mount(&server)
1062            .await;
1063        for ws in ["wrk_one", "wrk_two"] {
1064            Mock::given(method("GET"))
1065                .and(path(format!("/workspace/{}/go", ws)))
1066                .respond_with(
1067                    ResponseTemplate::new(200).set_body_string(dashboard_page(10.0, 50.0, None)),
1068                )
1069                .mount(&server)
1070                .await;
1071        }
1072
1073        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
1074        let mut ctx = ProviderContext::new();
1075        ctx.config.insert("cookie".into(), "session=abc".into());
1076        // wrk_one pinned without a name (enriched from discovery);
1077        // wrk_two has a manual name, which wins over the discovered one.
1078        ctx.config
1079            .insert("workspaces".into(), "wrk_one,wrk_two=Manual".into());
1080
1081        let snap = provider.fetch_usage(&ctx).await.unwrap();
1082        assert_eq!(
1083            snap.primary_rate_window.unwrap().label,
1084            "Production Rolling (5h)"
1085        );
1086        assert_eq!(snap.extra_rate_windows[0].label, "Manual Rolling (5h)");
1087    }
1088
1089    #[tokio::test]
1090    async fn test_pinned_ids_work_when_discovery_fails() {
1091        let server = MockServer::start().await;
1092
1093        Mock::given(method("GET"))
1094            .and(path("/_server"))
1095            .respond_with(ResponseTemplate::new(500))
1096            .mount(&server)
1097            .await;
1098        Mock::given(method("GET"))
1099            .and(path("/workspace/wrk_one/go"))
1100            .respond_with(
1101                ResponseTemplate::new(200).set_body_string(dashboard_page(10.0, 50.0, None)),
1102            )
1103            .mount(&server)
1104            .await;
1105
1106        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
1107        let mut ctx = ProviderContext::new();
1108        ctx.config.insert("cookie".into(), "session=abc".into());
1109        ctx.config.insert("workspaces".into(), "wrk_one".into());
1110
1111        // Name enrichment is best-effort; a broken discovery endpoint must
1112        // not break pinned workspaces.
1113        let snap = provider.fetch_usage(&ctx).await.unwrap();
1114        assert_eq!(
1115            snap.primary_rate_window.unwrap().label,
1116            "wrk_one Rolling (5h)"
1117        );
1118    }
1119
1120    #[tokio::test]
1121    async fn test_signed_out_page_is_auth_error() {
1122        let server = MockServer::start().await;
1123        Mock::given(method("GET"))
1124            .and(path("/workspace/wrk_x/go"))
1125            .respond_with(ResponseTemplate::new(200).set_body_string("<html>Please sign in</html>"))
1126            .mount(&server)
1127            .await;
1128
1129        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
1130        let mut ctx = ProviderContext::new();
1131        ctx.config.insert("cookie".into(), "stale=1".into());
1132        ctx.config.insert("workspaces".into(), "wrk_x".into());
1133
1134        let result = provider.fetch_usage(&ctx).await;
1135        assert!(matches!(result, Err(SpendPanelError::AuthFailed(_, _))));
1136    }
1137
1138    #[tokio::test]
1139    async fn test_partial_workspace_failure_keeps_successes() {
1140        let server = MockServer::start().await;
1141        Mock::given(method("GET"))
1142            .and(path("/workspace/wrk_ok/go"))
1143            .respond_with(
1144                ResponseTemplate::new(200).set_body_string(dashboard_page(20.0, 40.0, None)),
1145            )
1146            .mount(&server)
1147            .await;
1148        Mock::given(method("GET"))
1149            .and(path("/workspace/wrk_broken/go"))
1150            .respond_with(ResponseTemplate::new(500))
1151            .mount(&server)
1152            .await;
1153
1154        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
1155        let mut ctx = ProviderContext::new();
1156        ctx.config.insert("cookie".into(), "session=abc".into());
1157        ctx.config
1158            .insert("workspaces".into(), "wrk_ok,wrk_broken".into());
1159
1160        let snap = provider.fetch_usage(&ctx).await.unwrap();
1161        assert!(snap.primary_rate_window.is_some());
1162        assert!(snap.extra_rate_windows.is_empty());
1163    }
1164
1165    #[tokio::test]
1166    async fn test_all_workspaces_fail_returns_error() {
1167        let server = MockServer::start().await;
1168        Mock::given(method("GET"))
1169            .and(path("/workspace/wrk_a/go"))
1170            .respond_with(ResponseTemplate::new(500))
1171            .mount(&server)
1172            .await;
1173
1174        let provider = OpenCodeGoProvider::with_base_url(&server.uri());
1175        let mut ctx = ProviderContext::new();
1176        ctx.config.insert("cookie".into(), "session=abc".into());
1177        ctx.config.insert("workspaces".into(), "wrk_a".into());
1178
1179        let result = provider.fetch_usage(&ctx).await;
1180        assert!(matches!(result, Err(SpendPanelError::ProviderError(_, _))));
1181    }
1182}