1use 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";
16const 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#[derive(Debug, Clone, Copy, PartialEq)]
25struct ParsedWindow {
26 percent: f64,
28 reset_in_sec: i64,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct WorkspaceRef {
37 pub id: String,
38 pub name: Option<String>,
39}
40
41impl WorkspaceRef {
42 pub fn display_name(&self) -> &str {
44 self.name.as_deref().unwrap_or(&self.id)
45 }
46
47 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#[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: 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 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 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 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 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 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 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 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
300fn 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
333pub 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
348pub 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
384pub 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
424pub 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
444fn 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 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
469fn 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
486fn parse_window(text: &str, window_key: &str) -> Option<ParsedWindow> {
489 for (start, _) in text.match_indices(window_key) {
490 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 return Some(ParsedWindow {
509 percent: percent.clamp(0.0, 100.0),
510 reset_in_sec,
511 });
512 }
513
514 None
515}
516
517fn 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 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 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#[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 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 let v = add_workspace(&v, "wrk_a", Some("Staging")).unwrap();
673 assert_eq!(v, vec!["wrk_a=Staging"]);
674 let err = add_workspace(&v, "wrk_a", None).unwrap_err();
676 assert!(err.to_string().contains("already configured"));
677 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 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 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 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()); 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 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 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 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}