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.
195 lines
6.0 KiB
Rust
195 lines
6.0 KiB
Rust
//! 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();
|
|
} |