ocd/src/main.rs
ocd 3b1d2a4389 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.
2026-07-30 01:07:30 +03:00

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();
}