Scaffold ocd: config, HTTP fetch, cache, paced pipeline, render
Rust CLI with clap subcommands (pretty/raw json/raw plain/fetch/cache/config). Per-workspace dual HTTP (Zen balance + Go limits) run with tokio::join, 500 ms workspace pacing, TTL-180s file cache, retry/backoff for transient failures. scrape.rs holds the only OpenCode-coupled HTML selectors (placeholders until the real Zen/Go pages are captured). Unicode blitter-block bars in pretty mode; stable JSON/plain raw output for widgets and agent plugins.
This commit is contained in:
parent
769982e5c9
commit
3b1d2a4389
3
.gitignore
vendored
3
.gitignore
vendored
@ -1 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
|
/target
|
||||||
|
/tmp/
|
||||||
|
*.toml.bak
|
||||||
2598
Cargo.lock
generated
Normal file
2598
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
Cargo.toml
Normal file
32
Cargo.toml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "ocd"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "OpenCode limits and balance dashboard"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://example.invalid/ocd"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ocd"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anstyle = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
clap = { version = "4.6", features = ["derive", "env", "cargo"] }
|
||||||
|
directories = "6.0"
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots", "gzip", "json", "charset", "http2"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
scraper = "0.27"
|
||||||
|
terminal_size = "0.4"
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] }
|
||||||
|
toml = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
56
README.md
Normal file
56
README.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# ocd
|
||||||
|
|
||||||
|
Console dashboard for opencode account limits and balance.
|
||||||
|
|
||||||
|
`ocd` reads a config of opencode accounts and auth cookies. It scrapes the
|
||||||
|
opencode Zen page for balance and the Go page for limits. It prints usage for
|
||||||
|
the 5-hour, week, and month time frames, each reset time, the next update time,
|
||||||
|
and remaining balance.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo install --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
```
|
||||||
|
ocd config init
|
||||||
|
$EDITOR ocd.toml
|
||||||
|
ocd config check # lists accounts and workspaces, no network
|
||||||
|
```
|
||||||
|
|
||||||
|
Each account section carries `default_workspace` and optional `extra_workspaces`.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[account]]
|
||||||
|
name = "work"
|
||||||
|
cookie = "${OCD_COOKIE_WORK}"
|
||||||
|
default_workspace = "ws_123"
|
||||||
|
extra_workspaces = ["ws_456"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`${VAR}` expands from the environment so cookies stay out of the file.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```
|
||||||
|
ocd pretty # default terminal output
|
||||||
|
ocd raw json # one JSON object for widgets and agent plugins
|
||||||
|
ocd raw plain # TSV-ish lines for shell and awk
|
||||||
|
ocd fetch # update cache, print nothing; for cron
|
||||||
|
ocd cache ls | clear
|
||||||
|
ocd -a work -w ws_456 pretty # one account, one workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## Politeness
|
||||||
|
|
||||||
|
Per workspace two requests run at once (Zen, Go). The walk paces 500 ms between
|
||||||
|
workspaces. Cache TTL defaults to 180 s. Use `--force-refresh` to bypass.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This is a scaffold. The HTTP, cache, pacing, and render pipeline work. The
|
||||||
|
opencode HTML selectors in `src/scrape.rs` are placeholders until the real Zen
|
||||||
|
and Go pages are captured. See `DESIGN.md`.
|
||||||
15
examples/ocd.toml
Normal file
15
examples/ocd.toml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Example ocd config. Copy to ~/.config/ocd/config.toml and edit.
|
||||||
|
# Cookies are the full Cookie header value. ${VAR} expands from the environment.
|
||||||
|
default_concurrency = 1
|
||||||
|
default_ttl_secs = 180
|
||||||
|
|
||||||
|
[[account]]
|
||||||
|
name = "work"
|
||||||
|
cookie = "${OCD_COOKIE_WORK}"
|
||||||
|
default_workspace = "ws_REPLACE_ME"
|
||||||
|
extra_workspaces = []
|
||||||
|
|
||||||
|
[[account]]
|
||||||
|
name = "personal"
|
||||||
|
cookie = "ocd_session=...."
|
||||||
|
default_workspace = "ws_REPLACE_ME"
|
||||||
118
src/cache.rs
Normal file
118
src/cache.rs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
//! File-backed cache, keyed by (account, workspace).
|
||||||
|
//!
|
||||||
|
//! Stores the parsed report as JSON under the XDG cache dir. Honors a TTL.
|
||||||
|
//! Carries optional `etag` and `last_modified` so the HTTP layer can do
|
||||||
|
//! conditional GET. The path is stable, so widgets can read it directly.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::model::AccountReport;
|
||||||
|
|
||||||
|
/// One cached entry: the report plus conditional-GET headers from both pages.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CachedRecord {
|
||||||
|
pub report: AccountReport,
|
||||||
|
pub go_etag: Option<String>,
|
||||||
|
pub go_last_modified: Option<String>,
|
||||||
|
pub zen_etag: Option<String>,
|
||||||
|
pub zen_last_modified: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the cache root. The root is `$XDG_CACHE_HOME/ocd/` or
|
||||||
|
/// `~/.cache/ocd/`.
|
||||||
|
pub fn cache_dir() -> PathBuf {
|
||||||
|
directories::ProjectDirs::from("", "", "ocd")
|
||||||
|
.map(|p| p.cache_dir().to_path_buf())
|
||||||
|
.unwrap_or_else(|| PathBuf::from(".cache/ocd"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File path for one (account, workspace) target.
|
||||||
|
pub fn entry_path(account: &str, workspace: &str) -> PathBuf {
|
||||||
|
cache_dir().join(format!("{}.{}.json", slug(account), slug(workspace)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a cache entry if it is fresh. Fresh means file mtime is within `ttl`
|
||||||
|
/// seconds of now. Missing or expired entries return None.
|
||||||
|
pub fn read(account: &str, workspace: &str, ttl_secs: u32) -> Option<CachedRecord> {
|
||||||
|
let path = entry_path(account, workspace);
|
||||||
|
let meta = std::fs::metadata(&path).ok()?;
|
||||||
|
let age = age_secs(&meta).ok()?;
|
||||||
|
if age > ttl_secs as u64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body = std::fs::read_to_string(&path).ok()?;
|
||||||
|
serde_json::from_str(&body).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a cache entry. Creates the cache dir on demand.
|
||||||
|
pub fn write(account: &str, workspace: &str, record: &CachedRecord) -> std::io::Result<()> {
|
||||||
|
let path = entry_path(account, workspace);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let body = serde_json::to_vec_pretty(record)?;
|
||||||
|
std::fs::write(&path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a cache entry. Returns true if a file was removed.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn clear(account: &str, workspace: &str) -> std::io::Result<bool> {
|
||||||
|
let path = entry_path(account, workspace);
|
||||||
|
match std::fs::remove_file(&path) {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List cache entries with account and workspace labels.
|
||||||
|
pub fn list() -> Vec<(String, String)> {
|
||||||
|
let dir = cache_dir();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let Ok(rd) = std::fs::read_dir(&dir) else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
let p = entry.path();
|
||||||
|
if p.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
|
if let Some((a, w)) = stem.rsplit_once('.') {
|
||||||
|
out.push((a.to_string(), w.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove every cache entry. Returns the count removed.
|
||||||
|
pub fn clear_all() -> std::io::Result<usize> {
|
||||||
|
let dir = cache_dir();
|
||||||
|
let mut n = 0;
|
||||||
|
for entry in std::fs::read_dir(&dir)?.flatten() {
|
||||||
|
let p = entry.path();
|
||||||
|
if p.extension().and_then(|e| e.to_str()) == Some("json") {
|
||||||
|
if std::fs::remove_file(&p).is_ok() {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slug(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn age_secs(meta: &std::fs::Metadata) -> std::io::Result<u64> {
|
||||||
|
let mt = meta.modified()?;
|
||||||
|
let dur = std::time::SystemTime::now().duration_since(mt).map_err(|e| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
|
||||||
|
})?;
|
||||||
|
Ok(dur.as_secs())
|
||||||
|
}
|
||||||
122
src/cli.rs
Normal file
122
src/cli.rs
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
//! clap definitions. A bare `ocd` maps to `ocd pretty`.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::{ArgAction, Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "ocd", version, about = "OpenCode limits and balance dashboard")]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<Command>,
|
||||||
|
|
||||||
|
/// Path to the config file. Env: OCD_CONFIG.
|
||||||
|
#[arg(long, short = 'c', global = true, env = "OCD_CONFIG", value_name = "PATH")]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Ignore the cache for this run. Always fetch.
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub no_cache: bool,
|
||||||
|
|
||||||
|
/// Force a refresh even when cache is fresh. Implies a network fetch.
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub force_refresh: bool,
|
||||||
|
|
||||||
|
/// Cache TTL in seconds. Default 180.
|
||||||
|
#[arg(long, global = true, value_name = "SECS")]
|
||||||
|
pub ttl: Option<u32>,
|
||||||
|
|
||||||
|
/// Per request timeout in seconds. Default 15.
|
||||||
|
#[arg(long, global = true, value_name = "SECS", default_value_t = 15)]
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
|
||||||
|
/// Max retries for transient failures. Default 3.
|
||||||
|
#[arg(long, global = true, value_name = "N", default_value_t = 3)]
|
||||||
|
pub retries: u8,
|
||||||
|
|
||||||
|
/// Max parallel workspaces. Default 1, because the walk stays sequential.
|
||||||
|
#[arg(long, short = 'j', global = true, value_name = "N")]
|
||||||
|
pub concurrency: Option<u8>,
|
||||||
|
|
||||||
|
/// Pace between workspaces in ms. Default 500.
|
||||||
|
#[arg(long, global = true, value_name = "MS", default_value_t = 500)]
|
||||||
|
pub pace_ms: u64,
|
||||||
|
|
||||||
|
/// Restrict to these account names.
|
||||||
|
#[arg(long, short = 'a', global = true, value_name = "NAME", num_args = 1)]
|
||||||
|
pub account: Option<Vec<String>>,
|
||||||
|
|
||||||
|
/// Restrict to these workspace ids.
|
||||||
|
#[arg(long, short = 'w', global = true, value_name = "ID", num_args = 1)]
|
||||||
|
pub workspace: Option<Vec<String>>,
|
||||||
|
|
||||||
|
/// Override the User-Agent.
|
||||||
|
#[arg(long, global = true, value_name = "UA")]
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
|
||||||
|
/// Turn color off.
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub no_color: bool,
|
||||||
|
|
||||||
|
/// Force ASCII bars instead of Unicode blocks.
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub ascii: bool,
|
||||||
|
|
||||||
|
/// Quiet output. Still sets exit codes.
|
||||||
|
#[arg(long, short = 'q', global = true)]
|
||||||
|
pub quiet: bool,
|
||||||
|
|
||||||
|
/// Verbosity: -v info, -vv debug, -vvv trace.
|
||||||
|
#[arg(long, short = 'v', global = true, action = ArgAction::Count)]
|
||||||
|
pub verbose: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Clone, Debug)]
|
||||||
|
pub enum Command {
|
||||||
|
/// Pretty terminal output. The default.
|
||||||
|
Pretty,
|
||||||
|
|
||||||
|
/// Machine-readable output for widgets and agent plugins.
|
||||||
|
Raw {
|
||||||
|
/// Output format: json or plain.
|
||||||
|
#[arg(default_value = "json")]
|
||||||
|
format: RawFormat,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Run the network fetch and update the cache. Print nothing. For cron.
|
||||||
|
Fetch,
|
||||||
|
|
||||||
|
/// Cache management.
|
||||||
|
Cache {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: CacheAction,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Config management.
|
||||||
|
Config {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: ConfigAction,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::ValueEnum, Clone, Debug)]
|
||||||
|
pub enum RawFormat {
|
||||||
|
Json,
|
||||||
|
Plain,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Clone, Debug)]
|
||||||
|
pub enum CacheAction {
|
||||||
|
/// List cached (account, workspace) entries.
|
||||||
|
Ls,
|
||||||
|
/// Clear the whole cache.
|
||||||
|
Clear,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Clone, Debug)]
|
||||||
|
pub enum ConfigAction {
|
||||||
|
/// Write a template config to the given path, default `ocd.toml`.
|
||||||
|
Init { path: Option<PathBuf> },
|
||||||
|
/// Parse and validate the config file. List accounts. No network.
|
||||||
|
Check,
|
||||||
|
}
|
||||||
119
src/client.rs
Normal file
119
src/client.rs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
//! Thin HTTP client. One `reqwest::Client` instance serves all workspaces so
|
||||||
|
//! connections pool across the host (one TLS handshake for the run).
|
||||||
|
//!
|
||||||
|
//! The `get_text` method does retry and backoff for transient failures. The
|
||||||
|
//! caller fires `get_text` twice per workspace (Zen and Go) with `tokio::join`.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::error::FetchError;
|
||||||
|
|
||||||
|
/// Balance comes from the Zen page. Limits come from the Go page.
|
||||||
|
///
|
||||||
|
/// TODO: Confirm the real base URLs and the `{workspace}` path. Replace these
|
||||||
|
/// constants once the pages are captured.
|
||||||
|
pub const ZEN_BASE_URL: &str = "https://opencode.ai/zen/{workspace}";
|
||||||
|
pub const GO_BASE_URL: &str = "https://opencode.ai/go/{workspace}";
|
||||||
|
|
||||||
|
pub fn zen_url(workspace: &str) -> String {
|
||||||
|
ZEN_BASE_URL.replace("{workspace}", workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go_url(workspace: &str) -> String {
|
||||||
|
GO_BASE_URL.replace("{workspace}", workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HttpClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
retries: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClient {
|
||||||
|
pub fn new(user_agent: String, timeout_secs: u64, retries: u8) -> anyhow::Result<Self> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(timeout_secs))
|
||||||
|
.user_agent(user_agent)
|
||||||
|
.build()?;
|
||||||
|
Ok(Self { client, retries })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET a page as text with the account cookie. Retries transient errors
|
||||||
|
/// with exponential backoff and jitter. Returns the response body.
|
||||||
|
pub async fn get_text(&self, url: &str, cookie: &str) -> Result<String, FetchError> {
|
||||||
|
let mut attempt: u8 = 0;
|
||||||
|
loop {
|
||||||
|
match self.get_once(url, cookie).await {
|
||||||
|
Ok(body) => return Ok(body),
|
||||||
|
Err(e) if e.is_transient() && attempt < self.retries => {
|
||||||
|
let backoff = backoff_for(attempt);
|
||||||
|
tracing::warn!(attempt, backoff_ms = backoff.as_millis(), error = %e, "retry");
|
||||||
|
tokio::time::sleep(backoff).await;
|
||||||
|
attempt += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_once(&self, url: &str, cookie: &str) -> Result<String, FetchError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.header("cookie", cookie)
|
||||||
|
.header("accept", "text/html,application/xhtml+xml,*/*;q=0.8")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.is_timeout() {
|
||||||
|
FetchError::Timeout
|
||||||
|
} else {
|
||||||
|
FetchError::Send(e.to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if status == 429 {
|
||||||
|
return Err(FetchError::RateLimited);
|
||||||
|
}
|
||||||
|
if status == 304 {
|
||||||
|
return Err(FetchError::Send("unexpected 304".to_string()));
|
||||||
|
}
|
||||||
|
if !(200..300).contains(&status) {
|
||||||
|
return Err(FetchError::Status {
|
||||||
|
status,
|
||||||
|
url: url.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| FetchError::Body(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backoff for `attempt` (0-based): 500 ms, 1 s, 2 s, capped at 8 s, plus
|
||||||
|
/// jitter up to 25 per cent of each step.
|
||||||
|
fn backoff_for(attempt: u8) -> Duration {
|
||||||
|
let step = 500u64 << attempt.min(4);
|
||||||
|
let cap = 8_000u64;
|
||||||
|
let ms = step.min(cap);
|
||||||
|
let jitter = (ms / 4).max(1);
|
||||||
|
let extra = (prng() % jitter) as u64;
|
||||||
|
Duration::from_millis(ms + extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cheap, allocation-free PRNG seeded from thread time. Only feeds jitter, so
|
||||||
|
/// quality does not matter. We avoid pulling in `rand` for this.
|
||||||
|
fn prng() -> u64 {
|
||||||
|
use std::time::SystemTime;
|
||||||
|
let since_epoch = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0) as u64;
|
||||||
|
let mut x = since_epoch.wrapping_mul(0x9E37_79B9_7F4A_7C15).max(1);
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 7;
|
||||||
|
x ^= x << 17;
|
||||||
|
x
|
||||||
|
}
|
||||||
149
src/config.rs
Normal file
149
src/config.rs
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
//! Config load, `${VAR}` expansion, path discovery.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::error::LoadError;
|
||||||
|
|
||||||
|
/// Top-level TOML config. Maps directly to the file in DESIGN.md.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub default_concurrency: Option<u8>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_ttl_secs: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub account: Vec<Account>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One opencode account. Owns its auth cookie and workspace ids.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Account {
|
||||||
|
/// Human label shown in output.
|
||||||
|
pub name: String,
|
||||||
|
/// Value of the Cookie header. `${VAR}` expands on load.
|
||||||
|
pub cookie: String,
|
||||||
|
/// Workspace id fetched by default.
|
||||||
|
pub default_workspace: String,
|
||||||
|
/// Extra workspace ids to also fetch.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_workspaces: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
/// All workspace ids for this account: default first, then extras, deduped
|
||||||
|
/// in order.
|
||||||
|
pub fn workspace_ids(&self) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::with_capacity(1 + self.extra_workspaces.len());
|
||||||
|
out.push(self.default_workspace.clone());
|
||||||
|
for ws in &self.extra_workspaces {
|
||||||
|
if !out.contains(ws) {
|
||||||
|
out.push(ws.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a config file. Precedence: explicit path, `$OCD_CONFIG`, `./ocd.toml`,
|
||||||
|
/// then `$XDG_CONFIG_HOME/ocd/config.toml`.
|
||||||
|
pub fn discover(explicit: Option<&Path>) -> Result<PathBuf, LoadError> {
|
||||||
|
if let Some(p) = explicit {
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(p.to_path_buf());
|
||||||
|
}
|
||||||
|
return Err(LoadError::NotFound(p.to_path_buf()));
|
||||||
|
}
|
||||||
|
if let Ok(env) = std::env::var("OCD_CONFIG") {
|
||||||
|
let p = PathBuf::from(env);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let local = PathBuf::from("ocd.toml");
|
||||||
|
if local.exists() {
|
||||||
|
return Ok(local);
|
||||||
|
}
|
||||||
|
if let Some(proj) = directories::ProjectDirs::from("", "", "ocd") {
|
||||||
|
let p = proj.config_dir().join("config.toml");
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(LoadError::NotFound(PathBuf::from(
|
||||||
|
"$OCD_CONFIG | ./ocd.toml | $XDG_CONFIG_HOME/ocd/config.toml",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and parse a config file from `path`. Expands `${VAR}` in string
|
||||||
|
/// values.
|
||||||
|
pub fn load(path: &Path) -> Result<Config, LoadError> {
|
||||||
|
let raw = std::fs::read_to_string(path).map_err(|source| LoadError::Io {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let expanded = expand_vars(&raw);
|
||||||
|
let cfg: Config =
|
||||||
|
toml::from_str(&expanded).map_err(|e| LoadError::Parse(e.to_string()))?;
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace `${VAR}` (or `${VAR:-default}`) with environment values. Used on the
|
||||||
|
/// raw text before TOML parse, so values land intact and quoted.
|
||||||
|
fn expand_vars(raw: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(raw.len());
|
||||||
|
let bytes = raw.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
// Look for "${".
|
||||||
|
if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
|
||||||
|
if let Some(end) = find_close(&bytes[i + 2..]) {
|
||||||
|
let token = &raw[i + 2..i + 2 + end];
|
||||||
|
out.push_str(&resolve_token(token));
|
||||||
|
i = i + 2 + end + 1; // skip "${", token, "}"
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[i] as char);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the index of the closing "}".
|
||||||
|
fn find_close(rest: &[u8]) -> Option<usize> {
|
||||||
|
rest.iter().position(|&b| b == b'}')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a `${VAR}` or `${VAR:-fallback}` token.
|
||||||
|
fn resolve_token(token: &str) -> String {
|
||||||
|
if let Some((name, default)) = token.split_once(":-") {
|
||||||
|
std::env::var(name).unwrap_or_else(|_| default.to_string())
|
||||||
|
} else {
|
||||||
|
std::env::var(token).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The template written by `ocd config init`.
|
||||||
|
pub const TEMPLATE: &str = r#"# ocd config. See DESIGN.md.
|
||||||
|
default_concurrency = 1
|
||||||
|
default_ttl_secs = 180
|
||||||
|
|
||||||
|
# One block per account. Cookie is the full Cookie header value.
|
||||||
|
# ${VAR} expands from the environment. Keep secrets out of this file.
|
||||||
|
[[account]]
|
||||||
|
name = "work"
|
||||||
|
cookie = "${OCD_COOKIE_WORK}"
|
||||||
|
default_workspace = "ws_REPLACE_ME"
|
||||||
|
extra_workspaces = []
|
||||||
|
|
||||||
|
[[account]]
|
||||||
|
name = "personal"
|
||||||
|
cookie = "ocd_session=...."
|
||||||
|
default_workspace = "ws_REPLACE_ME"
|
||||||
|
"#;
|
||||||
63
src/error.rs
Normal file
63
src/error.rs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
//! Typed errors for config load, HTTP fetch, and HTML scrape.
|
||||||
|
//!
|
||||||
|
//! anyhow carries these at boundaries. thiserror keeps the variants typed so
|
||||||
|
//! the fetch loop can branch on transient versus hard failures.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Failures while reading or parsing the config file.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum LoadError {
|
||||||
|
#[error("config file not found at {0}")]
|
||||||
|
NotFound(std::path::PathBuf),
|
||||||
|
|
||||||
|
#[error("could not read config file {path}: {source}")]
|
||||||
|
Io {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("could not parse config as TOML: {0}")]
|
||||||
|
Parse(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failures while fetching a page over HTTP.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum FetchError {
|
||||||
|
#[error("request timed out")]
|
||||||
|
Timeout,
|
||||||
|
|
||||||
|
#[error("rate limited (HTTP 429)")]
|
||||||
|
RateLimited,
|
||||||
|
|
||||||
|
#[error("HTTP {status} from {url}")]
|
||||||
|
Status { status: u16, url: String },
|
||||||
|
|
||||||
|
#[error("request failed: {0}")]
|
||||||
|
Send(String),
|
||||||
|
|
||||||
|
#[error("could not read body: {0}")]
|
||||||
|
Body(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FetchError {
|
||||||
|
/// True when the error is worth a retry. 5xx, timeouts, and network errors
|
||||||
|
/// qualify. 4xx (except 429, handled above) does not.
|
||||||
|
pub fn is_transient(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
FetchError::Timeout | FetchError::Send(_) | FetchError::Body(_) => true,
|
||||||
|
FetchError::Status { status, .. } if *status >= 500 => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failures while turning a fetched HTML page into the data model.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ScrapeError {
|
||||||
|
#[error("opencode HTML selectors are not configured yet; see scrape.rs: {hint}")]
|
||||||
|
NotConfigured { hint: String },
|
||||||
|
|
||||||
|
#[error("could not parse page: {0}")]
|
||||||
|
Parse(String),
|
||||||
|
}
|
||||||
185
src/fetch.rs
Normal file
185
src/fetch.rs
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
//! The paced workspace walk.
|
||||||
|
//!
|
||||||
|
//! Targets are flattened into one ordered list of (account, workspace). The
|
||||||
|
//! walk is sequential with 500 ms pacing. Per workspace two requests run at
|
||||||
|
//! once: Zen for balance and Go for limits. Fresh cache skips the network.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::cache::{self, CachedRecord};
|
||||||
|
use crate::client::{self, HttpClient};
|
||||||
|
use crate::model::{AccountReport, Balance, Limits, ReportStatus};
|
||||||
|
use crate::scrape;
|
||||||
|
|
||||||
|
/// One fetch target: an account and one workspace of it, with the cookie.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Target {
|
||||||
|
pub account: String,
|
||||||
|
pub workspace: String,
|
||||||
|
pub cookie: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved pipeline options from the CLI.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FetchOptions {
|
||||||
|
pub ttl_secs: u32,
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
pub retries: u8,
|
||||||
|
pub force_refresh: bool,
|
||||||
|
pub no_cache: bool,
|
||||||
|
pub workspace_pace_ms: u64,
|
||||||
|
pub account_filter: Vec<String>,
|
||||||
|
pub workspace_filter: Vec<String>,
|
||||||
|
pub user_agent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the target list from the config, applying CLI filters.
|
||||||
|
pub fn targets(cfg: &crate::config::Config, opts: &FetchOptions) -> Vec<Target> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for acct in &cfg.account {
|
||||||
|
if !opts.account_filter.is_empty() && !opts.account_filter.contains(&acct.name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for ws in acct.workspace_ids() {
|
||||||
|
if !opts.workspace_filter.is_empty() && !opts.workspace_filter.contains(&ws) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(Target {
|
||||||
|
account: acct.name.clone(),
|
||||||
|
workspace: ws,
|
||||||
|
cookie: acct.cookie.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk all targets. Returns one report per target, in config order.
|
||||||
|
pub async fn run(cfg: &crate::config::Config, opts: &FetchOptions) -> anyhow::Result<Vec<AccountReport>> {
|
||||||
|
let http = HttpClient::new(opts.user_agent.clone(), opts.timeout_secs, opts.retries)?;
|
||||||
|
let targets = targets(cfg, opts);
|
||||||
|
|
||||||
|
let mut reports = Vec::with_capacity(targets.len());
|
||||||
|
for (i, tgt) in targets.iter().enumerate() {
|
||||||
|
let report = fetch_target(tgt, opts, &http).await;
|
||||||
|
reports.push(report);
|
||||||
|
|
||||||
|
// Pace before the next workspace. Do not pace after the last.
|
||||||
|
if i + 1 < targets.len() && opts.workspace_pace_ms > 0 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(opts.workspace_pace_ms)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(reports)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch one target. Cache short-circuits the network.
|
||||||
|
async fn fetch_target(tgt: &Target, opts: &FetchOptions, http: &HttpClient) -> AccountReport {
|
||||||
|
// Cache hit path.
|
||||||
|
if !opts.force_refresh && !opts.no_cache {
|
||||||
|
if let Some(rec) = cache::read(&tgt.account, &tgt.workspace, opts.ttl_secs) {
|
||||||
|
let mut r = rec.report;
|
||||||
|
r.status = ReportStatus::Cached;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network path. Fire Zen and Go at once.
|
||||||
|
let zen_url = client::zen_url(&tgt.workspace);
|
||||||
|
let go_url = client::go_url(&tgt.workspace);
|
||||||
|
let (zen_res, go_res) = tokio::join!(
|
||||||
|
http.get_text(&zen_url, &tgt.cookie),
|
||||||
|
http.get_text(&go_url, &tgt.cookie),
|
||||||
|
);
|
||||||
|
|
||||||
|
let fetched_at = Some(Utc::now().to_rfc3339());
|
||||||
|
let balance = match &zen_res {
|
||||||
|
Ok(html) => match scrape::parse_zen_balance(html) {
|
||||||
|
Ok(b) => Some(b),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(account = %tgt.account, workspace = %tgt.workspace, error = %e, "zen parse failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(account = %tgt.account, workspace = %tgt.workspace, error = %e, "zen fetch failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let limits = match &go_res {
|
||||||
|
Ok(html) => match scrape::parse_go_limits(html) {
|
||||||
|
Ok(l) => Some(l),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(account = %tgt.account, workspace = %tgt.workspace, error = %e, "go parse failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(account = %tgt.account, workspace = %tgt.workspace, error = %e, "go fetch failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decide the status. RateLimited and Timeout take priority over generic
|
||||||
|
// Error so callers can react.
|
||||||
|
let status = report_status(&zen_res, &go_res, limits.as_ref(), balance.as_ref());
|
||||||
|
|
||||||
|
let report = AccountReport {
|
||||||
|
account: tgt.account.clone(),
|
||||||
|
workspace: tgt.workspace.clone(),
|
||||||
|
fetched_at,
|
||||||
|
status: status.clone(),
|
||||||
|
limits: limits.clone(),
|
||||||
|
balance: balance.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write cache only on a clean Ok, so errors never poison the cache.
|
||||||
|
if status == ReportStatus::Ok {
|
||||||
|
let record = CachedRecord {
|
||||||
|
report: report.clone(),
|
||||||
|
go_etag: None,
|
||||||
|
go_last_modified: None,
|
||||||
|
zen_etag: None,
|
||||||
|
zen_last_modified: None,
|
||||||
|
};
|
||||||
|
if let Err(e) = cache::write(&tgt.account, &tgt.workspace, &record) {
|
||||||
|
tracing::warn!(error = %e, "cache write failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold the two fetch results into one status.
|
||||||
|
fn report_status(
|
||||||
|
zen: &Result<String, crate::error::FetchError>,
|
||||||
|
go: &Result<String, crate::error::FetchError>,
|
||||||
|
limits: Option<&Limits>,
|
||||||
|
balance: Option<&Balance>,
|
||||||
|
) -> ReportStatus {
|
||||||
|
use crate::error::FetchError;
|
||||||
|
for r in [zen, go] {
|
||||||
|
match r {
|
||||||
|
Err(FetchError::RateLimited) => return ReportStatus::RateLimited,
|
||||||
|
Err(FetchError::Timeout) => return ReportStatus::Timeout,
|
||||||
|
Err(FetchError::Status { status, .. }) if *status == 429 => {
|
||||||
|
return ReportStatus::RateLimited;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for r in [zen, go] {
|
||||||
|
if let Err(e) = r {
|
||||||
|
if limits.is_none() && balance.is_none() {
|
||||||
|
return ReportStatus::Error(e.to_string());
|
||||||
|
}
|
||||||
|
return ReportStatus::Error(e.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limits.is_some() || balance.is_some() {
|
||||||
|
ReportStatus::Ok
|
||||||
|
} else {
|
||||||
|
ReportStatus::Error("no limits or balance parsed".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src/main.rs
Normal file
195
src/main.rs
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
//! ocd entry point.
|
||||||
|
|
||||||
|
mod cache;
|
||||||
|
mod cli;
|
||||||
|
mod client;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod fetch;
|
||||||
|
mod model;
|
||||||
|
mod render;
|
||||||
|
mod scrape;
|
||||||
|
mod timefmt;
|
||||||
|
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
use cli::{CacheAction, Cli, Command, ConfigAction, RawFormat};
|
||||||
|
use model::{AccountReport, ReportStatus};
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("ocd: could not start runtime: {e}");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init_tracing(cli.verbose);
|
||||||
|
|
||||||
|
let result: anyhow::Result<std::process::ExitCode> =
|
||||||
|
rt.block_on(async { run(cli).await });
|
||||||
|
match result {
|
||||||
|
Ok(code) => code,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("ocd: {e:#}");
|
||||||
|
ExitCode::from(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(cli: Cli) -> anyhow::Result<ExitCode> {
|
||||||
|
let command = cli.command.clone().unwrap_or(Command::Pretty);
|
||||||
|
match command {
|
||||||
|
Command::Pretty => {
|
||||||
|
let (cfg, opts) = load_and_options(&cli)?;
|
||||||
|
let reports = fetch::run(&cfg, &opts).await?;
|
||||||
|
print_summary(&reports, !cli.quiet, || {
|
||||||
|
render::render_pretty(&reports, cli.ascii, cli.no_color || !stdout_is_tty())
|
||||||
|
})?;
|
||||||
|
Ok(exit_code(&reports))
|
||||||
|
}
|
||||||
|
Command::Raw { format } => {
|
||||||
|
let (cfg, opts) = load_and_options(&cli)?;
|
||||||
|
let reports = fetch::run(&cfg, &opts).await?;
|
||||||
|
print_summary(&reports, !cli.quiet, || match format {
|
||||||
|
RawFormat::Json => render::raw::render_json(&reports, true),
|
||||||
|
RawFormat::Plain => render::raw::render_plain(&reports),
|
||||||
|
})?;
|
||||||
|
Ok(exit_code(&reports))
|
||||||
|
}
|
||||||
|
Command::Fetch => {
|
||||||
|
let (cfg, opts) = load_and_options(&cli)?;
|
||||||
|
let reports = fetch::run(&cfg, &opts).await?;
|
||||||
|
let ok = reports
|
||||||
|
.iter()
|
||||||
|
.filter(|r| matches!(r.status, ReportStatus::Ok | ReportStatus::Cached))
|
||||||
|
.count();
|
||||||
|
tracing::info!(ok, total = reports.len(), "fetch done");
|
||||||
|
Ok(exit_code(&reports))
|
||||||
|
}
|
||||||
|
Command::Cache { action } => match action {
|
||||||
|
CacheAction::Ls => {
|
||||||
|
let entries = cache::list();
|
||||||
|
if entries.is_empty() {
|
||||||
|
println!("cache is empty");
|
||||||
|
} else {
|
||||||
|
println!("account\tworkspace");
|
||||||
|
for (a, w) in entries {
|
||||||
|
println!("{a}\t{w}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ExitCode::SUCCESS)
|
||||||
|
}
|
||||||
|
CacheAction::Clear => {
|
||||||
|
let n = cache::clear_all()?;
|
||||||
|
println!("cleared {n} entries");
|
||||||
|
Ok(ExitCode::SUCCESS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Command::Config { action } => match action {
|
||||||
|
ConfigAction::Init { path } => {
|
||||||
|
let path = path.unwrap_or_else(|| std::path::PathBuf::from("ocd.toml"));
|
||||||
|
if path.exists() {
|
||||||
|
anyhow::bail!("refusing to overwrite existing {}", path.display());
|
||||||
|
}
|
||||||
|
if let Some(p) = path.parent() {
|
||||||
|
std::fs::create_dir_all(p)?;
|
||||||
|
}
|
||||||
|
std::fs::write(&path, config::TEMPLATE)?;
|
||||||
|
println!("wrote {}", path.display());
|
||||||
|
Ok(ExitCode::SUCCESS)
|
||||||
|
}
|
||||||
|
ConfigAction::Check => {
|
||||||
|
let path = config::discover(cli.config.as_deref())?;
|
||||||
|
let cfg = config::load(&path)?;
|
||||||
|
println!("config ok: {}", path.display());
|
||||||
|
for a in &cfg.account {
|
||||||
|
let workspaces = a.workspace_ids();
|
||||||
|
println!(
|
||||||
|
" account \"{}\": {} workspace(s): {}",
|
||||||
|
a.name, workspaces.len(), workspaces.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(ExitCode::SUCCESS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the config and resolve the fetch options from CLI flags plus config
|
||||||
|
/// defaults.
|
||||||
|
fn load_and_options(cli: &Cli) -> anyhow::Result<(config::Config, fetch::FetchOptions)> {
|
||||||
|
let path = config::discover(cli.config.as_deref())?;
|
||||||
|
let cfg = config::load(&path)?;
|
||||||
|
let user_agent = cli
|
||||||
|
.user_agent
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("ocd/{}", env!("CARGO_PKG_VERSION")));
|
||||||
|
let ttl = cli.ttl.or(cfg.default_ttl_secs).unwrap_or(180);
|
||||||
|
let opts = fetch::FetchOptions {
|
||||||
|
ttl_secs: ttl,
|
||||||
|
timeout_secs: cli.timeout_secs,
|
||||||
|
retries: cli.retries,
|
||||||
|
force_refresh: cli.force_refresh,
|
||||||
|
no_cache: cli.no_cache,
|
||||||
|
workspace_pace_ms: cli.pace_ms,
|
||||||
|
account_filter: cli.account.clone().unwrap_or_default(),
|
||||||
|
workspace_filter: cli.workspace.clone().unwrap_or_default(),
|
||||||
|
user_agent,
|
||||||
|
};
|
||||||
|
Ok((cfg, opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print the rendered output unless quiet. The renderer runs lazily so quiet
|
||||||
|
/// mode does not pay for it.
|
||||||
|
fn print_summary(
|
||||||
|
_reports: &[AccountReport],
|
||||||
|
emit: bool,
|
||||||
|
render: impl FnOnce() -> String,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if emit {
|
||||||
|
print!("{}", render());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exit 0 if at least one account is Ok or Cached. Exit 1 if all failed.
|
||||||
|
fn exit_code(reports: &[AccountReport]) -> ExitCode {
|
||||||
|
let any_ok = reports
|
||||||
|
.iter()
|
||||||
|
.any(|r| matches!(r.status, ReportStatus::Ok | ReportStatus::Cached));
|
||||||
|
if any_ok {
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
} else {
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stdout_is_tty() -> bool {
|
||||||
|
use std::io::IsTerminal;
|
||||||
|
std::io::stdout().is_terminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing(verbose: u8) {
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
let level = match verbose {
|
||||||
|
0 => "warn",
|
||||||
|
1 => "info",
|
||||||
|
2 => "debug",
|
||||||
|
_ => "trace",
|
||||||
|
};
|
||||||
|
let filter =
|
||||||
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
86
src/model.rs
Normal file
86
src/model.rs
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
//! The normalized data model that flows from scrape to render.
|
||||||
|
//!
|
||||||
|
//! All datetimes are ISO 8601 UTC strings, for example "2025-08-31T17:00:00Z".
|
||||||
|
//! Keeping times as strings avoids serde feature churn. `timefmt` parses them
|
||||||
|
//! for human strings.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Bumped when the JSON shape changes. Widgets pin to this value.
|
||||||
|
pub const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// The full machine-readable output for `ocd raw json`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Envelope {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub generated_at: String,
|
||||||
|
pub accounts: Vec<AccountReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One report per (account, workspace) pair.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AccountReport {
|
||||||
|
/// Config name of the account.
|
||||||
|
pub account: String,
|
||||||
|
/// Workspace id.
|
||||||
|
pub workspace: String,
|
||||||
|
/// When the live fetch happened. None for a cache hit with no stored time.
|
||||||
|
pub fetched_at: Option<String>,
|
||||||
|
pub status: ReportStatus,
|
||||||
|
pub limits: Option<Limits>,
|
||||||
|
pub balance: Option<Balance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-workspace outcome.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ReportStatus {
|
||||||
|
/// Fetched from the network this run.
|
||||||
|
Ok,
|
||||||
|
/// Served from local cache.
|
||||||
|
Cached,
|
||||||
|
/// HTTP 429 from the server.
|
||||||
|
RateLimited,
|
||||||
|
/// A request exceeded the timeout after retries.
|
||||||
|
Timeout,
|
||||||
|
/// Any other failure. The string explains it.
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Limits {
|
||||||
|
/// 5-hour window. Activity based.
|
||||||
|
pub usage_5h: UsageFrame,
|
||||||
|
/// Weekly. Resets every Monday.
|
||||||
|
pub usage_week: UsageFrame,
|
||||||
|
/// Monthly. Resets 31 days from subscription date.
|
||||||
|
pub usage_month: UsageFrame,
|
||||||
|
/// Next time the server updates these frames, if the page reports it.
|
||||||
|
pub next_update: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UsageFrame {
|
||||||
|
pub used: f64,
|
||||||
|
pub total: f64,
|
||||||
|
/// "credits", "requests", "$", and so on.
|
||||||
|
pub unit: String,
|
||||||
|
pub resets_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Balance {
|
||||||
|
pub remaining: f64,
|
||||||
|
pub unit: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageFrame {
|
||||||
|
/// Used divided by total, as a percentage 0 to 100. Zero when total is zero.
|
||||||
|
pub fn pct(&self) -> f64 {
|
||||||
|
if self.total <= 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(self.used / self.total * 100.0).clamp(0.0, 100.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/render/mod.rs
Normal file
6
src/render/mod.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
//! Output renderers.
|
||||||
|
|
||||||
|
pub mod pretty;
|
||||||
|
pub mod raw;
|
||||||
|
|
||||||
|
pub use pretty::render_pretty;
|
||||||
169
src/render/pretty.rs
Normal file
169
src/render/pretty.rs
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
//! Pretty terminal renderer. Unicode blitter-block bars, colored by usage.
|
||||||
|
//! Degrades to ASCII with `--ascii`, and to no color with `--no-color` or when
|
||||||
|
//! stdout is not a terminal.
|
||||||
|
|
||||||
|
use anstyle::{AnsiColor, Style};
|
||||||
|
|
||||||
|
use crate::model::{AccountReport, ReportStatus, UsageFrame};
|
||||||
|
use crate::timefmt;
|
||||||
|
|
||||||
|
const EIGHTHS: [&str; 7] = ["▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
||||||
|
|
||||||
|
/// One report block, then the next. Reports are separated by a blank line.
|
||||||
|
pub fn render_pretty(reports: &[AccountReport], ascii: bool, no_color: bool) -> String {
|
||||||
|
let width = bar_width();
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, r) in reports.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(&render_one(r, width, ascii, no_color));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_one(r: &AccountReport, width: usize, ascii: bool, no_color: bool) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
|
||||||
|
// Header line.
|
||||||
|
let status = paint(&status_text(&r.status), status_style(&r.status), no_color);
|
||||||
|
let fetched = r
|
||||||
|
.fetched_at
|
||||||
|
.as_deref()
|
||||||
|
.map(timefmt::relative)
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
out.push_str(&format!("{} / {} {} fetched {}\n", r.account, r.workspace, status, fetched));
|
||||||
|
|
||||||
|
|
||||||
|
if let Some(l) = &r.limits {
|
||||||
|
out.push_str(&frame_row("5h", &l.usage_5h, width, ascii, no_color, "resets"));
|
||||||
|
out.push_str(&frame_row("week", &l.usage_week, width, ascii, no_color, "resets"));
|
||||||
|
out.push_str(&frame_row("month", &l.usage_month, width, ascii, no_color, "resets"));
|
||||||
|
if let Some(nu) = &l.next_update {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" next {}\n",
|
||||||
|
timefmt::absolute(nu),
|
||||||
|
));
|
||||||
|
out.push_str(&format!(
|
||||||
|
" ({})\n",
|
||||||
|
timefmt::relative(nu),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push_str(" limits unavailable\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(b) = &r.balance {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" balance {} {} remaining\n",
|
||||||
|
fmt_num(b.remaining),
|
||||||
|
b.unit,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
out.push_str(" balance unavailable\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the failure detail on an error status.
|
||||||
|
if let ReportStatus::Error(msg) = &r.status {
|
||||||
|
out.push_str(&format!(" error {msg}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_row(label: &str, frame: &UsageFrame, width: usize, ascii: bool, no_color: bool, _reset_tag: &str) -> String {
|
||||||
|
let pct = frame.pct();
|
||||||
|
let bar = colored_bar(pct, width, ascii, no_color);
|
||||||
|
let usage = if frame.total > 0.0 {
|
||||||
|
format!("{} / {} {}", fmt_num(frame.used), fmt_num(frame.total), frame.unit)
|
||||||
|
} else {
|
||||||
|
format!("{} {}", fmt_num(frame.used), frame.unit)
|
||||||
|
};
|
||||||
|
let resets = frame
|
||||||
|
.resets_at
|
||||||
|
.as_deref()
|
||||||
|
.map(|t| format!("resets in {}", timefmt::relative(t)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
" {:<7} {} {:<22} {:>5.1}% {}\n",
|
||||||
|
label, bar, usage, pct, resets
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn colored_bar(pct: f64, width: usize, ascii: bool, no_color: bool) -> String {
|
||||||
|
let total_eighths = ((pct / 100.0) * (width as f64 * 8.0)).round() as i64;
|
||||||
|
let total_eighths = total_eighths.clamp(0, (width as i64) * 8);
|
||||||
|
let whole = (total_eighths / 8) as usize;
|
||||||
|
let rem = (total_eighths % 8) as usize;
|
||||||
|
let mid = if rem > 0 { 1 } else { 0 };
|
||||||
|
let total_filled = whole + mid;
|
||||||
|
let empty = width.saturating_sub(total_filled);
|
||||||
|
|
||||||
|
let filled_str = if ascii {
|
||||||
|
"#".repeat(whole) + if rem > 0 { "#" } else { "" }
|
||||||
|
} else {
|
||||||
|
"█".repeat(whole) + if rem > 0 { EIGHTHS[rem - 1] } else { "" }
|
||||||
|
};
|
||||||
|
let empty_str = if ascii { "-".repeat(empty) } else { "░".repeat(empty) };
|
||||||
|
|
||||||
|
let style = zone_style(pct);
|
||||||
|
format!("{}{}{}", paint(&filled_str, style, no_color), empty_str, "" )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zone_style(pct: f64) -> Style {
|
||||||
|
if pct >= 90.0 {
|
||||||
|
Style::new().fg_color(Some(AnsiColor::BrightRed.into()))
|
||||||
|
} else if pct >= 70.0 {
|
||||||
|
Style::new().fg_color(Some(AnsiColor::Yellow.into()))
|
||||||
|
} else {
|
||||||
|
Style::new().fg_color(Some(AnsiColor::Green.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_text(s: &ReportStatus) -> String {
|
||||||
|
match s {
|
||||||
|
ReportStatus::Ok => "[ok]".to_string(),
|
||||||
|
ReportStatus::Cached => "[cached]".to_string(),
|
||||||
|
ReportStatus::RateLimited => "[rate limited]".to_string(),
|
||||||
|
ReportStatus::Timeout => "[timeout]".to_string(),
|
||||||
|
ReportStatus::Error(_) => "[error]".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_style(s: &ReportStatus) -> Style {
|
||||||
|
match s {
|
||||||
|
ReportStatus::Ok => Style::new().fg_color(Some(AnsiColor::Green.into())),
|
||||||
|
ReportStatus::Cached => Style::new().fg_color(Some(AnsiColor::Cyan.into())),
|
||||||
|
ReportStatus::RateLimited => Style::new().fg_color(Some(AnsiColor::Yellow.into())),
|
||||||
|
ReportStatus::Timeout => Style::new().fg_color(Some(AnsiColor::Magenta.into())),
|
||||||
|
ReportStatus::Error(_) => Style::new().fg_color(Some(AnsiColor::Red.into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(text: &str, style: Style, no_color: bool) -> String {
|
||||||
|
if no_color {
|
||||||
|
text.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}{}{}", style.render(), text, style.render_reset())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bar_width() -> usize {
|
||||||
|
let fallback = 20usize;
|
||||||
|
if let Some(terminal_size::Width(w)) = terminal_size::terminal_size().map(|(w, _)| w) {
|
||||||
|
(w as usize / 3).clamp(10, 44)
|
||||||
|
} else {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_num(n: f64) -> String {
|
||||||
|
let n = n.abs();
|
||||||
|
if n >= 1000.0 {
|
||||||
|
format!("{:.1}k", n / 1000.0)
|
||||||
|
} else if n.fract() == 0.0 {
|
||||||
|
format!("{}", n as i64)
|
||||||
|
} else {
|
||||||
|
format!("{:.2}", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/render/raw.rs
Normal file
64
src/render/raw.rs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
//! Machine-readable output for `ocd raw`.
|
||||||
|
|
||||||
|
use crate::model::{AccountReport, ReportStatus, SCHEMA_VERSION, UsageFrame, Envelope};
|
||||||
|
|
||||||
|
/// Serialize the envelope as JSON. `pretty` switches between an indented and a
|
||||||
|
/// compact form.
|
||||||
|
pub fn render_json(reports: &[AccountReport], pretty: bool) -> String {
|
||||||
|
let env = Envelope {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
generated_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
accounts: reports.to_vec(),
|
||||||
|
};
|
||||||
|
if pretty {
|
||||||
|
serde_json::to_string_pretty(&env).unwrap_or_else(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(&env).unwrap_or_else(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TSV-ish lines. One line per (account, workspace). Stable column order.
|
||||||
|
pub fn render_plain(reports: &[AccountReport]) -> String {
|
||||||
|
let blank = UsageFrame {
|
||||||
|
used: 0.0,
|
||||||
|
total: 0.0,
|
||||||
|
unit: String::new(),
|
||||||
|
resets_at: None,
|
||||||
|
};
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(
|
||||||
|
"account\tworkspace\tstatus\tused_5h\ttotal_5h\tunit_5h\tused_week\ttotal_week\tunit_week\tused_month\ttotal_month\tunit_month\tbalance_remaining\tbalance_unit\n",
|
||||||
|
);
|
||||||
|
for r in reports {
|
||||||
|
let (l5, lw, lm) = r.limits.as_ref().map_or_else(
|
||||||
|
|| (&blank, &blank, &blank),
|
||||||
|
|l| (&l.usage_5h, &l.usage_week, &l.usage_month),
|
||||||
|
);
|
||||||
|
let bal = r
|
||||||
|
.balance
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| (b.remaining, b.unit.as_str()))
|
||||||
|
.unwrap_or((0.0, ""));
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
|
||||||
|
r.account,
|
||||||
|
r.workspace,
|
||||||
|
status_word(&r.status),
|
||||||
|
l5.used, l5.total, l5.unit,
|
||||||
|
lw.used, lw.total, lw.unit,
|
||||||
|
lm.used, lm.total, lm.unit,
|
||||||
|
bal.0, bal.1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_word(s: &ReportStatus) -> &'static str {
|
||||||
|
match s {
|
||||||
|
ReportStatus::Ok => "ok",
|
||||||
|
ReportStatus::Cached => "cached",
|
||||||
|
ReportStatus::RateLimited => "rate_limited",
|
||||||
|
ReportStatus::Timeout => "timeout",
|
||||||
|
ReportStatus::Error(_) => "error",
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/scrape.rs
Normal file
60
src/scrape.rs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
//! HTML to model. The ONLY module coupled to the opencode page shape.
|
||||||
|
//!
|
||||||
|
//! When the page changes, edit selectors here. Transport, cache, and render do
|
||||||
|
//! not change. Each function returns the data for one page:
|
||||||
|
//!
|
||||||
|
//! - `parse_go_limits` reads the Go page. It returns `Limits`.
|
||||||
|
//! - `parse_zen_balance` reads the Zen page. It returns `Balance`.
|
||||||
|
//!
|
||||||
|
//! Both live in `scraper`. CSS selectors target small subtrees. The code below
|
||||||
|
//! is wired but the selectors are placeholders. Fill them from a captured page.
|
||||||
|
//! Until then the functions return `ScrapeError::NotConfigured`.
|
||||||
|
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
|
use crate::error::ScrapeError;
|
||||||
|
use crate::model::{Balance, Limits, UsageFrame};
|
||||||
|
|
||||||
|
/// Parse the Go page HTML into limits for the three frames plus next-update.
|
||||||
|
///
|
||||||
|
/// Plan once selectors are known:
|
||||||
|
/// 1. Selector the headline count for the 5h, week, and month frames.
|
||||||
|
/// 2. Read `used`, `total`, and `unit` from each frame block.
|
||||||
|
/// 3. Read `resets_at` per frame. The 5h frame is activity based, so the time
|
||||||
|
/// must come from the page. The week and month frames may be computed from
|
||||||
|
/// "every Monday" and "31 days from subscription" when absent.
|
||||||
|
/// 4. Read the "next update" banner into `next_update`.
|
||||||
|
pub fn parse_go_limits(html: &str) -> Result<Limits, ScrapeError> {
|
||||||
|
// `Html::parse_document` validates the HTML pipeline. Replace the rest.
|
||||||
|
let _document = Html::parse_document(html);
|
||||||
|
let _ = Selector::parse(":root").map_err(|e| ScrapeError::Parse(e.to_string()))?;
|
||||||
|
|
||||||
|
Err(ScrapeError::NotConfigured {
|
||||||
|
hint: "fill selectors for the 5h, week, and month frames in parse_go_limits"
|
||||||
|
.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the Zen page HTML into remaining balance.
|
||||||
|
///
|
||||||
|
/// Plan once selectors are known: selector the balance figure, the currency
|
||||||
|
/// unit, and optional time-of-fetch stamp.
|
||||||
|
pub fn parse_zen_balance(html: &str) -> Result<Balance, ScrapeError> {
|
||||||
|
let _document = Html::parse_document(html);
|
||||||
|
let _ = Selector::parse(":root").map_err(|e| ScrapeError::Parse(e.to_string()))?;
|
||||||
|
|
||||||
|
Err(ScrapeError::NotConfigured {
|
||||||
|
hint: "fill selectors for the balance figure and unit in parse_zen_balance".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper for future use: parse a frame block once the DOM is known.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn frame(used: f64, total: f64, unit: &str, resets_at: Option<String>) -> UsageFrame {
|
||||||
|
UsageFrame {
|
||||||
|
used,
|
||||||
|
total,
|
||||||
|
unit: unit.to_string(),
|
||||||
|
resets_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/timefmt.rs
Normal file
52
src/timefmt.rs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
//! Human time strings from ISO 8601 UTC timestamps.
|
||||||
|
//!
|
||||||
|
//! Used by the pretty renderer for "resets in 3h12m" and "in 47m". Parses are
|
||||||
|
//! lenient. A parse failure returns the raw string unchanged.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
/// Relative phrasing of a future or past timestamp. Example: "in 3h12m" or
|
||||||
|
/// "47m ago". Falls back to the input on parse failure.
|
||||||
|
pub fn relative(iso: &str) -> String {
|
||||||
|
let Some(t) = parse(iso) else {
|
||||||
|
return iso.to_string();
|
||||||
|
};
|
||||||
|
let now = Utc::now();
|
||||||
|
let d = t.signed_duration_since(now);
|
||||||
|
let secs = d.num_seconds();
|
||||||
|
|
||||||
|
if secs.abs() < 60 {
|
||||||
|
if secs >= 0 {
|
||||||
|
"in <1m".to_string()
|
||||||
|
} else {
|
||||||
|
"<1m ago".to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let h = secs / 3600;
|
||||||
|
let m = (secs % 3600) / 60;
|
||||||
|
let core = match (h.abs() > 0, m != 0) {
|
||||||
|
(true, true) => format!("{}h{}m", h.abs(), m.abs()),
|
||||||
|
(true, false) => format!("{}h", h.abs()),
|
||||||
|
(false, _) => format!("{}m", m.abs()),
|
||||||
|
};
|
||||||
|
if secs >= 0 {
|
||||||
|
format!("in {core}")
|
||||||
|
} else {
|
||||||
|
format!("{core} ago")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute UTC string like "2025-08-31 17:00 UTC".
|
||||||
|
pub fn absolute(iso: &str) -> String {
|
||||||
|
let Some(t) = parse(iso) else {
|
||||||
|
return iso.to_string();
|
||||||
|
};
|
||||||
|
t.format("%Y-%m-%d %H:%M UTC").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(iso: &str) -> Option<DateTime<Utc>> {
|
||||||
|
DateTime::parse_from_rfc3339(iso)
|
||||||
|
.ok()
|
||||||
|
.map(|dt| dt.with_timezone(&Utc))
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user